Skip to content

Commit b370518

Browse files
committed
fix: wip impl webhook registration via codegen
1 parent ba0296c commit b370518

File tree

3 files changed

+337
-0
lines changed

3 files changed

+337
-0
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import json
2+
import logging
3+
import requests
4+
5+
from pydantic import BaseModel
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
# --- TYPES
11+
12+
class LinearUser(BaseModel):
13+
id: str
14+
name: str
15+
16+
class LinearComment(BaseModel):
17+
id: str
18+
body: str
19+
user: LinearUser | None = None
20+
21+
class LinearIssue(BaseModel):
22+
id: str
23+
title: str
24+
description: str | None = None
25+
26+
27+
class LinearClient:
28+
api_headers: dict
29+
api_endpoint = "https://api.linear.app/graphql"
30+
31+
def __init__(self, access_token: str):
32+
self.access_token = access_token
33+
self.api_headers = {
34+
"Content-Type": "application/json",
35+
"Authorization": self.access_token,
36+
}
37+
38+
def get_issue(self, issue_id: str) -> LinearIssue:
39+
query = """
40+
query getIssue($issueId: String!) {
41+
issue(id: $issueId) {
42+
id
43+
title
44+
description
45+
}
46+
}
47+
"""
48+
variables = {"issueId": issue_id}
49+
response = requests.post(self.api_endpoint, headers=self.api_headers, json={"query": query, "variables": variables})
50+
data = response.json()
51+
issue_data = data["data"]["issue"]
52+
return LinearIssue(
53+
id=issue_data["id"],
54+
title=issue_data["title"],
55+
description=issue_data["description"]
56+
)
57+
58+
59+
def get_issue_comments(self, issue_id: str) -> list[LinearComment]:
60+
query = """
61+
query getIssueComments($issueId: String!) {
62+
issue(id: $issueId) {
63+
comments {
64+
nodes {
65+
id
66+
body
67+
user {
68+
id
69+
name
70+
}
71+
}
72+
73+
}
74+
}
75+
}
76+
"""
77+
variables = {"issueId": issue_id}
78+
response = requests.post(self.api_endpoint, headers=self.api_headers, json={"query": query, "variables": variables})
79+
data = response.json()
80+
comments = data["data"]["issue"]["comments"]["nodes"]
81+
82+
# Parse comments into list of LinearComment objects
83+
parsed_comments = []
84+
for comment in comments:
85+
user = comment.get("user", None)
86+
parsed_comment = LinearComment(
87+
id=comment["id"],
88+
body=comment["body"],
89+
user=LinearUser(
90+
id=user.get("id"),
91+
name=user.get("name")
92+
) if user else None
93+
)
94+
parsed_comments.append(parsed_comment)
95+
96+
# Convert raw comments to LinearComment objects
97+
return parsed_comments
98+
99+
100+
def comment_on_issue(self, issue_id: str, body: str) -> dict:
101+
"""issue_id is our internal issue ID"""
102+
query = """mutation makeComment($issueId: String!, $body: String!) {
103+
commentCreate(input: {issueId: $issueId, body: $body}) {
104+
comment {
105+
id
106+
body
107+
url
108+
user {
109+
id
110+
name
111+
}
112+
}
113+
}
114+
}
115+
"""
116+
variables = {"issueId": issue_id, "body": body}
117+
response = requests.post(
118+
self.api_endpoint,
119+
headers=self.api_headers,
120+
data=json.dumps({"query": query, "variables": variables}),
121+
)
122+
data = response.json()
123+
try:
124+
comment_data = data["data"]["commentCreate"]["comment"]
125+
126+
return comment_data
127+
except:
128+
raise Exception(f"Error creating comment\n{data}")
129+
130+
131+
def register_webhook(self, webhook_url: str, team_id: str, secret: str, enabled: bool, resource_types: list[str]):
132+
mutation = """
133+
mutation createWebhook($input: WebhookCreateInput!) {
134+
webhookCreate(input: $input) {
135+
success
136+
webhook {
137+
id
138+
enabled
139+
}
140+
}
141+
}
142+
"""
143+
144+
variables = {"input": {
145+
"url": webhook_url,
146+
"teamId": team_id,
147+
"resourceTypes": resource_types,
148+
"enabled": enabled,
149+
"secret": secret,
150+
}}
151+
152+
response = requests.post(self.api_endpoint, headers=self.api_headers, json={"query": mutation, "variables": variables})
153+
body = response.json()
154+
return body

src/codegen/extensions/events/app.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import functools
2+
import os
3+
from typing import Callable, Optional
4+
import modal
5+
import modal.runner
6+
7+
from codegen.extensions.clients.linear import LinearClient
8+
9+
class CodegenApp:
10+
def __init__(self, name, modal_api_key, image: modal.Image):
11+
self._modal_api_key = modal_api_key
12+
self._image = image
13+
self._name = name
14+
15+
self.modal_app = modal.App(name=name, image=image, )
16+
# Internal registry for event handlers, keyed by event name.
17+
self._registry = {}
18+
# Expose a linear attribute that provides the event decorator.
19+
self.linear = Linear(self)
20+
21+
def _register_event_handler(self, event_name, handler):
22+
"""Registers a handler for a given event."""
23+
self._registry.setdefault(event_name, []).append(handler)
24+
print(f"Registered handler '{handler}' for event '{event_name}'.")
25+
26+
def dispatch_event(self, event_name, request):
27+
"""
28+
Dispatches an event to all registered handlers for the event.
29+
"""
30+
handlers = self._registry.get(event_name, [])
31+
if not handlers:
32+
print(f"No handlers registered for event '{event_name}'.")
33+
return
34+
for handler in handlers:
35+
print(f"Dispatching event '{event_name}' to {handler.__name__}.")
36+
handler(request)
37+
38+
39+
40+
class Linear:
41+
def __init__(self, app: CodegenApp):
42+
self.app = app
43+
self.access_token= os.environ["LINEAR_ACCESS_TOKEN"] # move to extensions config.
44+
self.signing_secret = os.environ["LINEAR_SIGNING_SECRET"]
45+
self.linear_team_id = os.environ["LINEAR_TEAM_ID"]
46+
47+
48+
def register_handler_to_webhook(self, func_name: str, modal_app: modal.App, event_name):
49+
app_name = modal_app.name
50+
web_url = modal.Function.from_name(app_name=app_name,name=func_name)
51+
client = LinearClient(access_token=self.access_token)
52+
client.register_webhook(
53+
team_id=self.linear_team_id,
54+
webhook_url=web_url,
55+
enabled=True,
56+
resource_types=[event_name],
57+
secret=self.signing_secret)
58+
59+
# def unregister_handler(self):
60+
# TODO
61+
# pass
62+
63+
64+
def event(self, event_name, register_hook: Optional[Callable]=None):
65+
"""
66+
Decorator for registering an event handler.
67+
68+
:param event_name: The name of the event to handle.
69+
:param register_hook: An optional function to call during registration,
70+
e.g., to make an API call to register the webhook.
71+
"""
72+
def decorator(func):
73+
# Register the handler with the app's registry.
74+
75+
modal_ready_func = apply_decorators(func=func, decorators=[modal.web_endpoint(method="POST"), self.app.modal_app.function()])
76+
self.app._register_event_handler(event_name, modal_ready_func)
77+
78+
# If a custom registration hook is provided, execute it.
79+
if callable(register_hook):
80+
register_hook(event_name, func, self.app)
81+
82+
@functools.wraps(func)
83+
def wrapper(*args, **kwargs):
84+
return func(*args, **kwargs)
85+
86+
result = modal.runner.deploy_app(self.app.modal_app)
87+
print(result)
88+
return wrapper
89+
90+
91+
return decorator
92+
93+
94+
95+
96+
def apply_decorators(func, decorators):
97+
for decorator in decorators:
98+
func = decorator(func)
99+
return func
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": 4,
6+
"metadata": {},
7+
"outputs": [],
8+
"source": [
9+
"from codegen.extensions.events.app import CodegenApp\n",
10+
"import modal"
11+
]
12+
},
13+
{
14+
"cell_type": "code",
15+
"execution_count": 5,
16+
"metadata": {},
17+
"outputs": [],
18+
"source": [
19+
"image = modal.Image.debian_slim(python_version=\"3.13\").apt_install(\"git\").pip_install(\"fastapi[standard]\", \"codegen>=0.5.30\")\n",
20+
"\n",
21+
"app = CodegenApp(modal_api_key=\"\", image=image)"
22+
]
23+
},
24+
{
25+
"cell_type": "code",
26+
"execution_count": null,
27+
"metadata": {},
28+
"outputs": [
29+
{
30+
"name": "stdout",
31+
"output_type": "stream",
32+
"text": [
33+
"Registered handler 'test' for event 'Issue'.\n"
34+
]
35+
},
36+
{
37+
"ename": "TypeError",
38+
"evalue": "<lambda>() takes 1 positional argument but 3 were given",
39+
"output_type": "error",
40+
"traceback": [
41+
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
42+
"\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)",
43+
"Cell \u001b[0;32mIn[12], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;129;43m@app\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mlinear\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mevent\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mIssue\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mregister_hook\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;28;43;01mlambda\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mx\u001b[49m\u001b[43m:\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mprint\u001b[39;49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mtest statement\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 2\u001b[0m \u001b[38;5;28;43;01mdef\u001b[39;49;00m\u001b[38;5;250;43m \u001b[39;49m\u001b[38;5;21;43mtest\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\u001b[43m:\u001b[49m\n\u001b[1;32m 3\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43;01mpass\u001b[39;49;00m\n",
44+
"File \u001b[0;32m~/dev/codegen-sdk/src/codegen/extensions/events/app.py:50\u001b[0m, in \u001b[0;36mLinear.event.<locals>.decorator\u001b[0;34m(func)\u001b[0m\n\u001b[1;32m 48\u001b[0m \u001b[38;5;66;03m# If a custom registration hook is provided, execute it.\u001b[39;00m\n\u001b[1;32m 49\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mcallable\u001b[39m(register_hook):\n\u001b[0;32m---> 50\u001b[0m \u001b[43mregister_hook\u001b[49m\u001b[43m(\u001b[49m\u001b[43mevent_name\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mfunc\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mapp\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 52\u001b[0m \u001b[38;5;129m@functools\u001b[39m\u001b[38;5;241m.\u001b[39mwraps(func)\n\u001b[1;32m 53\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21mwrapper\u001b[39m(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs):\n\u001b[1;32m 54\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m func(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs)\n",
45+
"\u001b[0;31mTypeError\u001b[0m: <lambda>() takes 1 positional argument but 3 were given"
46+
]
47+
}
48+
],
49+
"source": [
50+
"@app.linear.event(\"Issue\", register_hook=lambda x,y,z: print(\"test statement\"))\n",
51+
"def test():\n",
52+
" pass"
53+
]
54+
},
55+
{
56+
"cell_type": "code",
57+
"execution_count": null,
58+
"metadata": {},
59+
"outputs": [],
60+
"source": []
61+
}
62+
],
63+
"metadata": {
64+
"kernelspec": {
65+
"display_name": ".venv",
66+
"language": "python",
67+
"name": "python3"
68+
},
69+
"language_info": {
70+
"codemirror_mode": {
71+
"name": "ipython",
72+
"version": 3
73+
},
74+
"file_extension": ".py",
75+
"mimetype": "text/x-python",
76+
"name": "python",
77+
"nbconvert_exporter": "python",
78+
"pygments_lexer": "ipython3",
79+
"version": "3.13.0"
80+
}
81+
},
82+
"nbformat": 4,
83+
"nbformat_minor": 2
84+
}

0 commit comments

Comments
 (0)