Skip to content

Commit cb3b725

Browse files
authored
ergonomic linear web hook registration (#484)
# Motivation <!-- Why is this change necessary? --> # Content <!-- Please include a summary of the change --> # Testing <!-- How was the change tested? --> # Please check the following before marking your PR as ready for review - [ ] I have added tests for my changes - [ ] I have updated the documentation or added new documentation as needed
1 parent 5d74c4c commit cb3b725

File tree

4 files changed

+273
-0
lines changed

4 files changed

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

src/codegen/extensions/events/app.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import logging
2+
3+
import modal
4+
5+
from codegen.extensions.events.linear import Linear
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class CodegenApp(modal.App):
11+
linear: Linear
12+
13+
def __init__(self, name, modal_api_key, image: modal.Image):
14+
self._modal_api_key = modal_api_key
15+
self._image = image
16+
self._name = name
17+
18+
super().__init__(name=name, image=image)
19+
20+
# Expose a attribute that provides the event decorator for different providers.
21+
self.linear = Linear(self)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from typing import Protocol
2+
3+
import modal
4+
5+
6+
class EventHandlerManagerProtocol(Protocol):
7+
def subscribe_handler_to_webhook(self, func_name: str, modal_app: modal.App, event_name):
8+
pass
9+
10+
def unsubscribe_handler_to_webhook(self, func_name: str, modal_app: modal.App, event_name):
11+
pass
12+
13+
def unsubscribe_all_handlers(self):
14+
pass
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import functools
2+
import logging
3+
import os
4+
from typing import Callable
5+
6+
import modal
7+
from anthropic import BaseModel
8+
9+
from codegen.extensions.clients.linear import LinearClient
10+
from codegen.extensions.events.interface import EventHandlerManagerProtocol
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class RegisteredWebhookHandler(BaseModel):
16+
webhook_id: str | None = None
17+
handler_func: Callable
18+
19+
20+
class Linear(EventHandlerManagerProtocol):
21+
def __init__(self, app: modal.App):
22+
self.app = app
23+
self.access_token = os.environ["LINEAR_ACCESS_TOKEN"] # move to extensions config.
24+
self.signing_secret = os.environ["LINEAR_SIGNING_SECRET"]
25+
self.linear_team_id = os.environ["LINEAR_TEAM_ID"]
26+
self.registered_handlers = {}
27+
28+
def subscribe_handler_to_webhook(self, web_url: str, event_name: str):
29+
client = LinearClient(access_token=self.access_token)
30+
31+
result = client.register_webhook(team_id=self.linear_team_id, webhook_url=web_url, enabled=True, resource_types=[event_name], secret=self.signing_secret)
32+
return result
33+
34+
def unsubscribe_handler_to_webhook(self, registered_handler: RegisteredWebhookHandler):
35+
webhook_id = registered_handler.webhook_id
36+
37+
client = LinearClient(access_token=self.access_token)
38+
if webhook_id:
39+
print(f"Unsubscribing from webhook {webhook_id}")
40+
result = client.unregister_webhook(webhook_id)
41+
return result
42+
else:
43+
print("No webhook id found for handler")
44+
return None
45+
46+
def unsubscribe_all_handlers(self):
47+
for handler in self.registered_handlers:
48+
self.unsubscribe_handler_to_webhook(self.registered_handlers[handler])
49+
50+
def event(self, event_name):
51+
"""Decorator for registering an event handler.
52+
53+
:param event_name: The name of the event to handle.
54+
:param register_hook: An optional function to call during registration,
55+
e.g., to make an API call to register the webhook.
56+
"""
57+
58+
def decorator(func):
59+
# Register the handler with the app's registry.
60+
modal_ready_func = func
61+
func_name = func.__qualname__
62+
app_name = self.app.name
63+
web_url = modal.Function.from_name(app_name=app_name, name=func_name).web_url
64+
65+
self.registered_handlers[func_name] = RegisteredWebhookHandler(handler_func=modal_ready_func)
66+
67+
webhook_id = self.subscribe_handler_to_webhook(web_url=web_url, event_name=event_name)
68+
self.registered_handlers[func_name].webhook_id = webhook_id
69+
70+
@functools.wraps(func)
71+
def wrapper(*args, **kwargs):
72+
return func(*args, **kwargs)
73+
74+
return wrapper
75+
76+
return decorator

0 commit comments

Comments
 (0)