Skip to content

Commit e913729

Browse files
jayhackrushilpatel0
authored andcommitted
feat: enables github webhooks (#518)
1 parent 27f0899 commit e913729

File tree

17 files changed

+491
-0
lines changed

17 files changed

+491
-0
lines changed

src/codegen/extensions/events/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import modal # deptry: ignore
44

5+
from codegen.extensions.events.github import GitHub
56
from codegen.extensions.events.linear import Linear
67
from codegen.extensions.events.slack import Slack
78

@@ -11,6 +12,7 @@
1112
class CodegenApp(modal.App):
1213
linear: Linear
1314
slack: Slack
15+
github: GitHub
1416

1517
def __init__(self, name: str, modal_api_key: str, image: modal.Image):
1618
self._modal_api_key = modal_api_key
@@ -22,3 +24,4 @@ def __init__(self, name: str, modal_api_key: str, image: modal.Image):
2224
# Expose attributes that provide event decorators for different providers.
2325
self.linear = Linear(self)
2426
self.slack = Slack(self)
27+
self.github = GitHub(self)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import logging
2+
from typing import Any, Callable, TypeVar
3+
4+
from fastapi import Request
5+
from pydantic import BaseModel
6+
7+
from codegen.extensions.events.interface import EventHandlerManagerProtocol
8+
from codegen.extensions.github.types.base import GitHubInstallation, GitHubWebhookPayload
9+
10+
logger = logging.getLogger(__name__)
11+
logger.setLevel(logging.DEBUG)
12+
13+
14+
# Type variable for event types
15+
T = TypeVar("T", bound=BaseModel)
16+
17+
18+
class GitHub(EventHandlerManagerProtocol):
19+
def __init__(self, app):
20+
self.app = app
21+
self.registered_handlers = {}
22+
23+
# TODO - add in client info
24+
# @property
25+
# def client(self) -> Github:
26+
# if not self._client:
27+
# self._client = Github(os.environ["GITHUB_TOKEN"])
28+
# return self._client
29+
30+
def unsubscribe_all_handlers(self):
31+
logger.info("[HANDLERS] Clearing all handlers")
32+
self.registered_handlers.clear()
33+
34+
def event(self, event_name: str):
35+
"""Decorator for registering a GitHub event handler.
36+
37+
Example:
38+
@app.github.event('push')
39+
def handle_push(event: PushEvent): # Can be typed with Pydantic model
40+
logger.info(f"Received push to {event.ref}")
41+
42+
@app.github.event('pull_request:opened')
43+
def handle_pr(event: dict): # Or just use dict for raw event
44+
logger.info(f"Received PR")
45+
"""
46+
logger.info(f"[EVENT] Registering handler for {event_name}")
47+
48+
def register_handler(func: Callable[[T], Any]):
49+
# Get the type annotation from the first parameter
50+
event_type = func.__annotations__.get("event")
51+
func_name = func.__qualname__
52+
logger.info(f"[EVENT] Registering function {func_name} for {event_name}")
53+
54+
def new_func(raw_event: dict):
55+
# Only validate if a Pydantic model was specified
56+
if event_type and issubclass(event_type, BaseModel):
57+
try:
58+
parsed_event = event_type.model_validate(raw_event)
59+
return func(parsed_event)
60+
except Exception as e:
61+
logger.exception(f"Error parsing event: {e}")
62+
raise
63+
else:
64+
# Pass through raw dict if no type validation needed
65+
return func(raw_event)
66+
67+
self.registered_handlers[event_name] = new_func
68+
return new_func
69+
70+
return register_handler
71+
72+
def handle(self, event: dict, request: Request):
73+
"""Handle both webhook events and installation callbacks."""
74+
logger.info("[HANDLER] Handling GitHub event")
75+
76+
# Check if this is an installation event
77+
if "installation_id" in event and "code" in event:
78+
installation = GitHubInstallation.model_validate(event)
79+
logger.info("=====[GITHUB APP INSTALLATION]=====")
80+
logger.info(f"Code: {installation.code}")
81+
logger.info(f"Installation ID: {installation.installation_id}")
82+
logger.info(f"Setup Action: {installation.setup_action}")
83+
return {
84+
"message": "GitHub app installation details received",
85+
"details": {
86+
"code": installation.code,
87+
"installation_id": installation.installation_id,
88+
"setup_action": installation.setup_action,
89+
},
90+
}
91+
92+
# Extract headers for webhook events
93+
headers = {
94+
"x-github-event": request.headers.get("x-github-event"),
95+
"x-github-delivery": request.headers.get("x-github-delivery"),
96+
"x-github-hook-id": request.headers.get("x-github-hook-id"),
97+
"x-github-hook-installation-target-id": request.headers.get("x-github-hook-installation-target-id"),
98+
"x-github-hook-installation-target-type": request.headers.get("x-github-hook-installation-target-type"),
99+
}
100+
print(headers)
101+
102+
# Handle webhook events
103+
try:
104+
webhook = GitHubWebhookPayload.model_validate({"headers": headers, "event": event})
105+
106+
# Get base event type and action
107+
event_type = webhook.headers.event_type
108+
action = webhook.event.action
109+
110+
# Combine event type and action if both exist
111+
full_event_type = f"{event_type}:{action}" if action else event_type
112+
113+
if full_event_type not in self.registered_handlers:
114+
logger.info(f"[HANDLER] No handler found for event type: {full_event_type}")
115+
return {"message": "Event type not handled"}
116+
117+
else:
118+
logger.info(f"[HANDLER] Handling event: {full_event_type}")
119+
handler = self.registered_handlers[full_event_type]
120+
return handler(event) # TODO - pass through typed values
121+
except Exception as e:
122+
logger.exception(f"Error handling webhook: {e}")
123+
raise
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from datetime import datetime
2+
from typing import Optional
3+
4+
5+
class GitHubRepository:
6+
id: int
7+
node_id: str
8+
name: str
9+
full_name: str
10+
private: bool
11+
12+
13+
class GitHubAccount:
14+
login: str
15+
id: int
16+
node_id: str
17+
avatar_url: str
18+
type: str
19+
site_admin: bool
20+
# Other URL fields omitted for brevity
21+
user_view_type: str
22+
23+
24+
class GitHubInstallation:
25+
id: int
26+
client_id: str
27+
account: GitHubAccount
28+
repository_selection: str
29+
access_tokens_url: str
30+
repositories_url: str
31+
html_url: str
32+
app_id: int
33+
app_slug: str
34+
target_id: int
35+
target_type: str
36+
permissions: dict[str, str] # e.g. {'actions': 'write', 'checks': 'read', ...}
37+
events: list[str]
38+
created_at: datetime
39+
updated_at: datetime
40+
single_file_name: Optional[str]
41+
has_multiple_single_files: bool
42+
single_file_paths: list[str]
43+
suspended_by: Optional[str]
44+
suspended_at: Optional[datetime]
45+
46+
47+
class GitHubUser:
48+
login: str
49+
id: int
50+
node_id: str
51+
avatar_url: str
52+
type: str
53+
site_admin: bool
54+
# Other URL fields omitted for brevity
55+
56+
57+
class GitHubInstallationEvent:
58+
action: str
59+
installation: GitHubInstallation
60+
repositories: list[GitHubRepository]
61+
requester: Optional[dict]
62+
sender: GitHubUser

src/codegen/extensions/github/__init__.py

Whitespace-only changes.

src/codegen/extensions/github/types/__init__.py

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from pydantic import BaseModel
2+
3+
4+
class GitHubAuthor(BaseModel):
5+
name: str
6+
email: str
7+
username: str
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from pydantic import BaseModel, Field
2+
3+
4+
class GitHubUser(BaseModel):
5+
login: str
6+
id: int
7+
node_id: str
8+
type: str
9+
10+
11+
class GitHubRepository(BaseModel):
12+
id: int
13+
node_id: str
14+
name: str
15+
full_name: str
16+
private: bool
17+
owner: GitHubUser
18+
19+
20+
class GitHubIssue(BaseModel):
21+
id: int
22+
node_id: str
23+
number: int
24+
title: str
25+
body: str | None
26+
user: GitHubUser
27+
state: str
28+
comments: int
29+
30+
31+
class GitHubPullRequest(BaseModel):
32+
id: int
33+
node_id: str
34+
number: int
35+
title: str
36+
body: str | None
37+
user: GitHubUser
38+
state: str
39+
head: dict
40+
base: dict
41+
merged: bool | None = None
42+
43+
44+
class GitHubEvent(BaseModel):
45+
action: str | None = None
46+
issue: GitHubIssue | None = None
47+
pull_request: GitHubPullRequest | None = None
48+
repository: GitHubRepository
49+
sender: GitHubUser
50+
51+
52+
class GitHubWebhookHeaders(BaseModel):
53+
event_type: str = Field(..., alias="x-github-event")
54+
delivery_id: str = Field(..., alias="x-github-delivery")
55+
hook_id: str = Field(..., alias="x-github-hook-id")
56+
installation_target_id: str = Field(..., alias="x-github-hook-installation-target-id")
57+
installation_target_type: str = Field(..., alias="x-github-hook-installation-target-type")
58+
59+
60+
class GitHubWebhookPayload(BaseModel):
61+
headers: GitHubWebhookHeaders
62+
event: GitHubEvent
63+
64+
65+
class GitHubInstallation(BaseModel):
66+
code: str
67+
installation_id: str
68+
setup_action: str = "install"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from pydantic import BaseModel
2+
3+
from .author import GitHubAuthor
4+
5+
6+
class GitHubCommit(BaseModel):
7+
id: str
8+
tree_id: str
9+
distinct: bool
10+
message: str
11+
timestamp: str
12+
url: str
13+
author: GitHubAuthor
14+
committer: GitHubAuthor
15+
added: list[str]
16+
removed: list[str]
17+
modified: list[str]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from pydantic import BaseModel
2+
3+
4+
class GitHubEnterprise(BaseModel):
5+
id: int
6+
slug: str
7+
name: str
8+
node_id: str
9+
avatar_url: str
10+
description: str
11+
website_url: str
12+
html_url: str
13+
created_at: str
14+
updated_at: str
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from pydantic import BaseModel
2+
3+
from ..base import GitHubRepository, GitHubUser
4+
from ..enterprise import GitHubEnterprise
5+
from ..installation import GitHubInstallation
6+
from ..label import GitHubLabel
7+
from ..organization import GitHubOrganization
8+
from ..pull_request import PullRequest
9+
10+
11+
class PullRequestLabeledEvent(BaseModel):
12+
action: str # Will be "labeled"
13+
number: int
14+
pull_request: PullRequest
15+
label: GitHubLabel
16+
repository: GitHubRepository
17+
organization: GitHubOrganization
18+
enterprise: GitHubEnterprise
19+
sender: GitHubUser
20+
installation: GitHubInstallation
21+
22+
23+
class PullRequestOpenedEvent(BaseModel):
24+
action: str = "opened" # Always "opened" for this event
25+
number: int
26+
pull_request: PullRequest
27+
repository: GitHubRepository
28+
organization: GitHubOrganization
29+
enterprise: GitHubEnterprise
30+
sender: GitHubUser
31+
installation: GitHubInstallation
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from pydantic import BaseModel
2+
3+
from ..base import GitHubRepository, GitHubUser
4+
from ..commit import GitHubCommit
5+
from ..enterprise import GitHubEnterprise
6+
from ..installation import GitHubInstallation
7+
from ..organization import GitHubOrganization
8+
from ..pusher import GitHubPusher
9+
10+
11+
class PushEvent(BaseModel):
12+
ref: str
13+
before: str
14+
after: str
15+
repository: GitHubRepository
16+
pusher: GitHubPusher
17+
organization: GitHubOrganization
18+
enterprise: GitHubEnterprise
19+
sender: GitHubUser
20+
installation: GitHubInstallation
21+
created: bool
22+
deleted: bool
23+
forced: bool
24+
base_ref: str | None = None
25+
compare: str
26+
commits: list[GitHubCommit]
27+
head_commit: GitHubCommit
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pydantic import BaseModel
2+
3+
4+
class GitHubInstallation(BaseModel):
5+
id: int
6+
node_id: str
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from pydantic import BaseModel
2+
3+
4+
class GitHubLabel(BaseModel):
5+
id: int
6+
node_id: str
7+
url: str
8+
name: str
9+
color: str
10+
default: bool
11+
description: str | None

0 commit comments

Comments
 (0)