diff --git a/codegen-examples/examples/codegen_app/README.md b/codegen-examples/examples/codegen_app/README.md
new file mode 100644
index 000000000..fda3c73d4
--- /dev/null
+++ b/codegen-examples/examples/codegen_app/README.md
@@ -0,0 +1,33 @@
+# Codegen App
+
+Simple example of running a codegen app.
+
+## Run Locally
+
+Spin up the server:
+
+```
+codegen serve
+```
+
+Spin up ngrok
+
+```
+ngrok http 8000
+```
+
+Go to Slack [app settings](https://api.slack.com/apps/A08CR9HUJ3W/event-subscriptions) and set the URL for event subscriptions
+
+```
+{ngrok_url}/slack/events
+```
+
+## Deploy to Modal
+
+This will deploy it as a function
+
+```
+modal deploy app.py
+```
+
+Then you can swap in the modal URL for slack etc.
diff --git a/codegen-examples/examples/codegen_app/app.py b/codegen-examples/examples/codegen_app/app.py
new file mode 100644
index 000000000..bbf61909a
--- /dev/null
+++ b/codegen-examples/examples/codegen_app/app.py
@@ -0,0 +1,100 @@
+import logging
+
+import modal
+from codegen import CodeAgent, CodegenApp
+from codegen.extensions.github.types.events.pull_request import PullRequestLabeledEvent
+from codegen.extensions.linear.types import LinearEvent
+from codegen.extensions.slack.types import SlackEvent
+from codegen.extensions.tools.github.create_pr_comment import create_pr_comment
+
+# Set up logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+########################################################################################################################
+# EVENTS
+########################################################################################################################
+
+# Create the cg_app
+cg = CodegenApp(name="codegen-test", repos=["codegen-sh/Kevin-s-Adventure-Game"])
+
+
+@cg.slack.event("app_mention")
+async def handle_mention(event: SlackEvent):
+ logger.info("[APP_MENTION] Received cg_app_mention event")
+ logger.info(event)
+
+ # Codebase
+ logger.info("[CODEBASE] Initializing codebase")
+ codebase = cg.get_codebase("codegen-sh/Kevin-s-Adventure-Game")
+
+ # Code Agent
+ logger.info("[CODE_AGENT] Initializing code agent")
+ agent = CodeAgent(codebase=codebase)
+
+ logger.info("[CODE_AGENT] Running code agent")
+ response = agent.run(event.text)
+
+ cg.slack.client.chat_postMessage(channel=event.channel, text=response, thread_ts=event.ts)
+ return {"message": "Mentioned", "received_text": event.text, "response": response}
+
+
+@cg.github.event("pull_request:labeled")
+def handle_pr(event: PullRequestLabeledEvent):
+ logger.info("PR labeled")
+ logger.info(f"PR head sha: {event.pull_request.head.sha}")
+ codebase = cg.get_codebase("codegen-sh/Kevin-s-Adventure-Game")
+
+ # =====[ Check out commit ]=====
+ # Might require fetch?
+ logger.info("> Checking out commit")
+ codebase.checkout(commit=event.pull_request.head.sha)
+
+ logger.info("> Getting files")
+ file = codebase.get_file("README.md")
+
+ # =====[ Create PR comment ]=====
+ create_pr_comment(codebase, event.pull_request.number, f"File content:\n```python\n{file.content}\n```")
+
+ return {"message": "PR event handled", "num_files": len(codebase.files), "num_functions": len(codebase.functions)}
+
+
+@cg.linear.event("Issue")
+def handle_issue(event: LinearEvent):
+ logger.info(f"Issue created: {event}")
+ codebase = cg.get_codebase("codegen-sh/Kevin-s-Adventure-Game")
+ return {"message": "Linear Issue event", "num_files": len(codebase.files), "num_functions": len(codebase.functions)}
+
+
+########################################################################################################################
+# MODAL DEPLOYMENT
+########################################################################################################################
+# This deploys the FastAPI app to Modal
+# TODO: link this up with memory snapshotting.
+
+# For deploying local package
+REPO_URL = "https://github.com/codegen-sh/codegen-sdk.git"
+COMMIT_ID = "26dafad2c319968e14b90806d42c6c7aaa627bb0"
+
+# Create the base image with dependencies
+base_image = (
+ modal.Image.debian_slim(python_version="3.13")
+ .apt_install("git")
+ .pip_install(
+ # =====[ Codegen ]=====
+ # "codegen",
+ f"git+{REPO_URL}@{COMMIT_ID}",
+ # =====[ Rest ]=====
+ "openai>=1.1.0",
+ "fastapi[standard]",
+ "slack_sdk",
+ )
+)
+
+app = modal.App("codegen-test")
+
+
+@app.function(image=base_image, secrets=[modal.Secret.from_dotenv()])
+@modal.asgi_app()
+def fastapi_app():
+ return cg.app
diff --git a/pyproject.toml b/pyproject.toml
index 8130f8811..bf3d7d71d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -71,6 +71,7 @@ dependencies = [
"slack-sdk",
"langchain-anthropic>=0.3.7",
"lox>=0.12.0",
+ "httpx>=0.28.1",
]
license = { text = "Apache-2.0" }
diff --git a/src/codegen/__init__.py b/src/codegen/__init__.py
index 7f37ec124..1b9b91d17 100644
--- a/src/codegen/__init__.py
+++ b/src/codegen/__init__.py
@@ -1,10 +1,11 @@
from codegen.agents.code_agent import CodeAgent
from codegen.cli.sdk.decorator import function
from codegen.cli.sdk.functions import Function
+from codegen.extensions.events.codegen_app import CodegenApp
# from codegen.extensions.index.file_index import FileIndex
# from codegen.extensions.langchain.agent import create_agent_with_tools, create_codebase_agent
from codegen.sdk.core.codebase import Codebase
from codegen.shared.enums.programming_language import ProgrammingLanguage
-__all__ = ["CodeAgent", "Codebase", "Function", "ProgrammingLanguage", "function"]
+__all__ = ["CodeAgent", "Codebase", "CodegenApp", "Function", "ProgrammingLanguage", "function"]
diff --git a/src/codegen/cli/cli.py b/src/codegen/cli/cli.py
index 737ec75f3..9ac7f69f4 100644
--- a/src/codegen/cli/cli.py
+++ b/src/codegen/cli/cli.py
@@ -16,6 +16,7 @@
from codegen.cli.commands.reset.main import reset_command
from codegen.cli.commands.run.main import run_command
from codegen.cli.commands.run_on_pr.main import run_on_pr_command
+from codegen.cli.commands.serve.main import serve_command
from codegen.cli.commands.start.main import start_command
from codegen.cli.commands.style_debug.main import style_debug_command
from codegen.cli.commands.update.main import update_command
@@ -48,6 +49,7 @@ def main():
main.add_command(update_command)
main.add_command(config_command)
main.add_command(lsp_command)
+main.add_command(serve_command)
main.add_command(start_command)
diff --git a/src/codegen/cli/commands/serve/main.py b/src/codegen/cli/commands/serve/main.py
new file mode 100644
index 000000000..3b8ff3eee
--- /dev/null
+++ b/src/codegen/cli/commands/serve/main.py
@@ -0,0 +1,212 @@
+import importlib.util
+import logging
+import socket
+import subprocess
+import sys
+from pathlib import Path
+from typing import Optional
+
+import rich
+import rich_click as click
+import uvicorn
+from rich.logging import RichHandler
+from rich.panel import Panel
+
+from codegen.extensions.events.codegen_app import CodegenApp
+
+logger = logging.getLogger(__name__)
+
+
+def setup_logging(debug: bool):
+ """Configure rich logging with colors."""
+ logging.basicConfig(
+ level=logging.DEBUG if debug else logging.INFO,
+ format="%(message)s",
+ handlers=[
+ RichHandler(
+ rich_tracebacks=True,
+ tracebacks_show_locals=debug,
+ markup=True,
+ show_time=False,
+ )
+ ],
+ )
+
+
+def load_app_from_file(file_path: Path) -> CodegenApp:
+ """Load a CodegenApp instance from a Python file.
+
+ Args:
+ file_path: Path to the Python file containing the CodegenApp
+
+ Returns:
+ The CodegenApp instance from the file
+
+ Raises:
+ click.ClickException: If no CodegenApp instance is found
+ """
+ try:
+ # Import the module from file path
+ spec = importlib.util.spec_from_file_location("app_module", file_path)
+ if not spec or not spec.loader:
+ msg = f"Could not load module from {file_path}"
+ raise click.ClickException(msg)
+
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+ # Find CodegenApp instance
+ for attr_name in dir(module):
+ attr = getattr(module, attr_name)
+ if isinstance(attr, CodegenApp):
+ return attr
+
+ msg = f"No CodegenApp instance found in {file_path}"
+ raise click.ClickException(msg)
+
+ except Exception as e:
+ msg = f"Error loading app from {file_path}: {e!s}"
+ raise click.ClickException(msg)
+
+
+def create_app_module(file_path: Path) -> str:
+ """Create a temporary module that exports the app for uvicorn."""
+ # Add the file's directory to Python path
+ file_dir = str(file_path.parent.absolute())
+ if file_dir not in sys.path:
+ sys.path.insert(0, file_dir)
+
+ # Create a module that imports and exposes the app
+ module_name = f"codegen_app_{file_path.stem}"
+ module_code = f"""
+from {file_path.stem} import app
+app = app.app
+"""
+ module_path = file_path.parent / f"{module_name}.py"
+ module_path.write_text(module_code)
+
+ return f"{module_name}:app"
+
+
+def start_ngrok(port: int) -> Optional[str]:
+ """Start ngrok and return the public URL"""
+ try:
+ import requests
+
+ # Start ngrok
+ process = subprocess.Popen(["ngrok", "http", str(port)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+ # Wait a moment for ngrok to start
+ import time
+
+ time.sleep(2)
+
+ # Get the public URL from ngrok's API
+ try:
+ response = requests.get("http://localhost:4040/api/tunnels")
+ data = response.json()
+
+ # Get the first https URL
+ for tunnel in data["tunnels"]:
+ if tunnel["proto"] == "https":
+ return tunnel["public_url"]
+
+ logger.warning("No HTTPS tunnel found")
+ return None
+
+ except requests.RequestException:
+ logger.exception("Failed to get ngrok URL from API")
+ logger.info("Get your public URL from: http://localhost:4040")
+ return None
+
+ except FileNotFoundError:
+ logger.exception("ngrok not found. Please install it first: https://ngrok.com/download")
+ return None
+
+
+def find_available_port(start_port: int = 8000, max_tries: int = 100) -> int:
+ """Find an available port starting from start_port."""
+ for port in range(start_port, start_port + max_tries):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("", port))
+ return port
+ except OSError:
+ continue
+ msg = f"No available ports found between {start_port} and {start_port + max_tries}"
+ raise click.ClickException(msg)
+
+
+@click.command(name="serve")
+@click.argument("file", type=click.Path(exists=True, path_type=Path))
+@click.option("--host", default="127.0.0.1", help="Host to bind to")
+@click.option("--port", default=8000, help="Port to bind to")
+@click.option("--debug", is_flag=True, help="Enable debug mode with hot reloading")
+@click.option("--public", is_flag=True, help="Expose the server publicly using ngrok")
+@click.option("--workers", default=1, help="Number of worker processes")
+@click.option("--repos", multiple=True, help="GitHub repositories to analyze")
+def serve_command(file: Path, host: str = "127.0.0.1", port: int = 8000, debug: bool = False, public: bool = False, workers: int = 4, repos: list[str] = []):
+ """Run a CodegenApp server from a Python file.
+
+ FILE is the path to a Python file containing a CodegenApp instance
+ """
+ # Configure rich logging
+ setup_logging(debug)
+
+ try:
+ if debug:
+ workers = 1
+
+ # Find available port if the specified one is in use
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind((host, port))
+ except OSError:
+ port = find_available_port(port)
+ logger.warning(f"Port {port} was in use, using port {port} instead")
+
+ # Always create module for uvicorn
+ app_import_string = create_app_module(file)
+ reload_dirs = [str(file.parent)] if debug else None
+
+ # Print server info
+ rich.print(
+ Panel(
+ f"[green]Starting CodegenApp server[/green]\n"
+ f"[dim]File:[/dim] {file}\n"
+ f"[dim]URL:[/dim] http://{host}:{port}\n"
+ f"[dim]Workers:[/dim] {workers}\n"
+ f"[dim]Debug:[/dim] {'enabled' if debug else 'disabled'}",
+ title="[bold]Server Info[/bold]",
+ border_style="blue",
+ )
+ )
+
+ # Start ngrok if --public flag is set
+ if public:
+ public_url = start_ngrok(port)
+ if public_url:
+ logger.info(f"Public URL: {public_url}")
+ logger.info("Use these webhook URLs in your integrations:")
+ logger.info(f" Slack: {public_url}/slack/events")
+ logger.info(f" GitHub: {public_url}/github/events")
+ logger.info(f" Linear: {public_url}/linear/events")
+
+ # Run the server with workers
+ uvicorn.run(
+ app_import_string,
+ host=host,
+ port=port,
+ reload=debug,
+ reload_dirs=reload_dirs,
+ log_level="debug" if debug else "info",
+ workers=workers,
+ )
+
+ except Exception as e:
+ msg = f"Server error: {e!s}"
+ raise click.ClickException(msg)
+
+
+if __name__ == "__main__":
+ serve_command()
diff --git a/src/codegen/extensions/events/client.py b/src/codegen/extensions/events/client.py
new file mode 100644
index 000000000..642b03354
--- /dev/null
+++ b/src/codegen/extensions/events/client.py
@@ -0,0 +1,87 @@
+from typing import Any
+
+import httpx
+from pydantic import BaseModel
+
+
+class SlackTestEvent(BaseModel):
+ """Helper class to construct test Slack events"""
+
+ user: str = "U123456"
+ type: str = "message"
+ ts: str = "1234567890.123456"
+ client_msg_id: str = "test-msg-id"
+ text: str = "Hello world"
+ team: str = "T123456"
+ channel: str = "C123456"
+ event_ts: str = "1234567890.123456"
+ blocks: list = []
+
+
+class CodegenClient:
+ """Client for testing CodegenApp endpoints"""
+
+ def __init__(self, base_url: str = "http://localhost:8000", timeout: float = 30.0):
+ self.base_url = base_url.rstrip("/")
+ self.client = httpx.AsyncClient(timeout=timeout)
+
+ async def send_slack_message(self, text: str, channel: str = "C123456", event_type: str = "message", **kwargs) -> dict[str, Any]:
+ """Send a test Slack message event
+
+ Args:
+ text: The message text
+ channel: The channel ID
+ event_type: The type of event (e.g. 'message', 'app_mention')
+ **kwargs: Additional fields to override in the event
+ """
+ event = SlackTestEvent(text=text, channel=channel, type=event_type, **kwargs)
+
+ payload = {
+ "token": "test_token",
+ "team_id": "T123456",
+ "api_app_id": "A123456",
+ "event": event.model_dump(),
+ "type": "event_callback",
+ "event_id": "Ev123456",
+ "event_time": 1234567890,
+ }
+
+ response = await self.client.post(f"{self.base_url}/slack/events", json=payload)
+ return response.json()
+
+ async def send_github_event(self, event_type: str, action: str | None = None, payload: dict | None = None) -> dict[str, Any]:
+ """Send a test GitHub webhook event
+
+ Args:
+ event_type: The type of event (e.g. 'pull_request', 'push')
+ action: The action for the event (e.g. 'labeled', 'opened')
+ payload: The event payload
+ """
+ # Construct headers that GitHub would send
+ headers = {
+ "x-github-event": event_type,
+ "x-github-delivery": "test-delivery-id",
+ "x-github-hook-id": "test-hook-id",
+ "x-github-hook-installation-target-id": "test-target-id",
+ "x-github-hook-installation-target-type": "repository",
+ }
+
+ response = await self.client.post(
+ f"{self.base_url}/github/events",
+ json=payload,
+ headers=headers,
+ )
+ return response.json()
+
+ async def send_linear_event(self, payload: dict) -> dict[str, Any]:
+ """Send a test Linear webhook event
+
+ Args:
+ payload: The event payload
+ """
+ response = await self.client.post(f"{self.base_url}/linear/events", json=payload)
+ return response.json()
+
+ async def close(self):
+ """Close the HTTP client"""
+ await self.client.aclose()
diff --git a/src/codegen/extensions/events/codegen_app.py b/src/codegen/extensions/events/codegen_app.py
new file mode 100644
index 000000000..f2e321c1d
--- /dev/null
+++ b/src/codegen/extensions/events/codegen_app.py
@@ -0,0 +1,163 @@
+import logging
+from typing import Any, Optional
+
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse
+
+from codegen.sdk.core.codebase import Codebase
+
+from .github import GitHub
+from .linear import Linear
+from .slack import Slack
+
+logger = logging.getLogger(__name__)
+
+
+class CodegenApp:
+ """A FastAPI-based application for handling various code-related events."""
+
+ github: GitHub
+ linear: Linear
+ slack: Slack
+
+ def __init__(self, name: str, repos: Optional[list[str]] = None, modal_api_key: Optional[str] = None, tmp_dir: str = "/tmp/codegen"):
+ self.name = name
+ self._modal_api_key = modal_api_key
+ self.tmp_dir = tmp_dir
+
+ # Create the FastAPI app
+ self.app = FastAPI(title=name)
+
+ # Initialize event handlers
+ self.linear = Linear(self)
+ self.slack = Slack(self)
+ self.github = GitHub(self)
+
+ # Initialize codebase cache
+ self.codebases: dict[str, Codebase] = {}
+
+ # Parse initial repos if provided
+ if repos:
+ for repo in repos:
+ self._parse_repo(repo)
+
+ # Register routes
+ self._setup_routes()
+
+ def _parse_repo(self, repo_name: str) -> None:
+ """Parse a GitHub repository and cache it.
+
+ Args:
+ repo_name: Repository name in format "owner/repo"
+ """
+ try:
+ logger.info(f"[CODEBASE] Parsing repository: {repo_name}")
+ self.codebases[repo_name] = Codebase.from_repo(repo_name, tmp_dir=self.tmp_dir)
+ logger.info(f"[CODEBASE] Successfully parsed and cached: {repo_name}")
+ except Exception as e:
+ logger.exception(f"[CODEBASE] Failed to parse repository {repo_name}: {e!s}")
+ raise
+
+ def get_codebase(self, repo_name: str) -> Codebase:
+ """Get a cached codebase by repository name.
+
+ Args:
+ repo_name: Repository name in format "owner/repo"
+
+ Returns:
+ The cached Codebase instance
+
+ Raises:
+ KeyError: If the repository hasn't been parsed
+ """
+ if repo_name not in self.codebases:
+ msg = f"Repository {repo_name} has not been parsed. Available repos: {list(self.codebases.keys())}"
+ raise KeyError(msg)
+ return self.codebases[repo_name]
+
+ def add_repo(self, repo_name: str) -> None:
+ """Add a new repository to parse and cache.
+
+ Args:
+ repo_name: Repository name in format "owner/repo"
+ """
+ self._parse_repo(repo_name)
+
+ async def simulate_event(self, provider: str, event_type: str, payload: dict) -> Any:
+ """Simulate an event without running the server.
+
+ Args:
+ provider: The event provider ('slack', 'github', or 'linear')
+ event_type: The type of event to simulate
+ payload: The event payload
+
+ Returns:
+ The handler's response
+ """
+ provider_map = {"slack": self.slack, "github": self.github, "linear": self.linear}
+
+ if provider not in provider_map:
+ msg = f"Unknown provider: {provider}. Must be one of {list(provider_map.keys())}"
+ raise ValueError(msg)
+
+ handler = provider_map[provider]
+ return await handler.handle(payload)
+
+ def _setup_routes(self):
+ """Set up the FastAPI routes for different event types."""
+
+ @self.app.get("/", response_class=HTMLResponse)
+ async def root():
+ """Render the main page."""
+ return """
+
+
+
+ Codegen
+
+
+
+ codegen
+
+
+ """
+
+ @self.app.post("/slack/events")
+ async def handle_slack_event(request: Request):
+ """Handle incoming Slack events."""
+ payload = await request.json()
+ return await self.slack.handle(payload)
+
+ @self.app.post("/github/events")
+ async def handle_github_event(request: Request):
+ """Handle incoming GitHub events."""
+ payload = await request.json()
+ return await self.github.handle(payload, request)
+
+ @self.app.post("/linear/events")
+ async def handle_linear_event(request: Request):
+ """Handle incoming Linear events."""
+ payload = await request.json()
+ return await self.linear.handle(payload)
+
+ def run(self, host: str = "0.0.0.0", port: int = 8000, **kwargs):
+ """Run the FastAPI application."""
+ import uvicorn
+
+ uvicorn.run(self.app, host=host, port=port, **kwargs)
diff --git a/src/codegen/extensions/events/github.py b/src/codegen/extensions/events/github.py
index 0c899ebb7..f4df1dc66 100644
--- a/src/codegen/extensions/events/github.py
+++ b/src/codegen/extensions/events/github.py
@@ -1,7 +1,9 @@
import logging
+import os
from typing import Any, Callable, TypeVar
from fastapi import Request
+from github import Github
from pydantic import BaseModel
from codegen.extensions.events.interface import EventHandlerManagerProtocol
@@ -20,12 +22,15 @@ 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
+ @property
+ def client(self) -> Github:
+ if not os.getenv("GITHUB_TOKEN"):
+ msg = "GITHUB_TOKEN is not set"
+ logger.exception(msg)
+ raise ValueError(msg)
+ if not self._client:
+ self._client = Github(os.getenv("GITHUB_TOKEN"))
+ return self._client
def unsubscribe_all_handlers(self):
logger.info("[HANDLERS] Clearing all handlers")
@@ -69,7 +74,7 @@ def new_func(raw_event: dict):
return register_handler
- def handle(self, event: dict, request: Request):
+ async def handle(self, event: dict, request: Request | None = None) -> dict:
"""Handle both webhook events and installation callbacks."""
logger.info("[HANDLER] Handling GitHub event")
@@ -89,35 +94,44 @@ def handle(self, event: dict, request: Request):
},
}
- # 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)
+ # Extract headers for webhook events if request is provided
+ headers = {}
+ if request:
+ 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"),
+ }
# Handle webhook events
try:
- webhook = GitHubWebhookPayload.model_validate({"headers": headers, "event": event})
+ # For simulation, use event data directly
+ if not request:
+ event_type = f"pull_request:{event['action']}" if "action" in event else event.get("type", "unknown")
+ if event_type not in self.registered_handlers:
+ logger.info(f"[HANDLER] No handler found for event type: {event_type}")
+ return {"message": "Event type not handled"}
+ else:
+ logger.info(f"[HANDLER] Handling event: {event_type}")
+ handler = self.registered_handlers[event_type]
+ return handler(event)
- # Get base event type and action
+ # For actual webhooks, use the full payload
+ webhook = GitHubWebhookPayload.model_validate({"headers": headers, "event": event})
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
+ return handler(event)
+
except Exception as e:
logger.exception(f"Error handling webhook: {e}")
raise
diff --git a/src/codegen/extensions/events/linear.py b/src/codegen/extensions/events/linear.py
index f1b97a04f..7c3bdf8db 100644
--- a/src/codegen/extensions/events/linear.py
+++ b/src/codegen/extensions/events/linear.py
@@ -1,83 +1,84 @@
-import functools
import logging
-import os
-from typing import Callable
+from typing import Any, Callable, TypeVar
-import modal # deptry: ignore
from pydantic import BaseModel
-from codegen.extensions.clients.linear import LinearClient
from codegen.extensions.events.interface import EventHandlerManagerProtocol
+from codegen.extensions.linear.types import LinearEvent
logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
-
-class RegisteredWebhookHandler(BaseModel):
- webhook_id: str | None = None
- handler_func: Callable
- event_name: str
+# Type variable for event types
+T = TypeVar("T", bound=BaseModel)
class Linear(EventHandlerManagerProtocol):
- def __init__(self, app: modal.App):
+ def __init__(self, app):
self.app = app
- self.access_token = os.environ["LINEAR_ACCESS_TOKEN"] # move to extensions config.
- self.signing_secret = os.environ["LINEAR_SIGNING_SECRET"]
- self.linear_team_id = os.environ["LINEAR_TEAM_ID"]
self.registered_handlers = {}
- self._webhook_url = None
-
- def subscribe_handler_to_webhook(self, handler: RegisteredWebhookHandler):
- client = LinearClient(access_token=self.access_token)
- web_url = modal.Function.from_name(app_name=self.app.name, name=handler.handler_func.__qualname__).web_url
- result = client.register_webhook(team_id=self.linear_team_id, webhook_url=web_url, enabled=True, resource_types=[handler.event_name], secret=self.signing_secret)
- return result
-
- def subscribe_all_handlers(self):
- for handler_key in self.registered_handlers:
- handler = self.registered_handlers[handler_key]
- result = self.subscribe_handler_to_webhook(handler=self.registered_handlers[handler_key])
- handler.webhook_id = result
-
- def unsubscribe_handler_to_webhook(self, registered_handler: RegisteredWebhookHandler):
- webhook_id = registered_handler.webhook_id
-
- client = LinearClient(access_token=self.access_token)
- if webhook_id:
- print(f"Unsubscribing from webhook {webhook_id}")
- result = client.unregister_webhook(webhook_id)
- return result
- else:
- print("No webhook id found for handler")
- return None
def unsubscribe_all_handlers(self):
- for handler in self.registered_handlers:
- self.unsubscribe_handler_to_webhook(self.registered_handlers[handler])
+ logger.info("[HANDLERS] Clearing all handlers")
+ self.registered_handlers.clear()
- def event(self, event_name, should_handle: Callable[[dict], bool] | None = None):
- """Decorator for registering an event handler.
+ def event(self, event_name: str):
+ """Decorator for registering a Linear event handler.
- :param event_name: The name of the event to handle.
- :param register_hook: An optional function to call during registration,
- e.g., to make an API call to register the webhook.
+ Args:
+ event_name: The type of event to handle (e.g. 'Issue', 'Comment')
"""
+ logger.info(f"[EVENT] Registering handler for {event_name}")
- def decorator(func):
- # Register the handler with the app's registry.
- modal_ready_func = func
+ def register_handler(func: Callable[[LinearEvent], Any]):
func_name = func.__qualname__
- self.registered_handlers[func_name] = RegisteredWebhookHandler(handler_func=modal_ready_func, event_name=event_name)
-
- @functools.wraps(func)
- def wrapper(*args, **kwargs):
- should_handle_result = should_handle(*args, **kwargs) if should_handle else True
- if should_handle is None or should_handle_result:
- return func(*args, **kwargs)
- else:
- logger.info(f"Skipping event {event_name} for {func_name}")
+ logger.info(f"[EVENT] Registering function {func_name} for {event_name}")
+
+ def new_func(raw_event: dict):
+ # Get event type from payload
+ event_type = raw_event.get("type")
+ if event_type != event_name:
+ logger.info(f"[HANDLER] Event type mismatch: expected {event_name}, got {event_type}")
return None
- return wrapper
+ # Parse event into LinearEvent type
+ event = LinearEvent.model_validate(raw_event)
+ return func(event)
+
+ self.registered_handlers[event_name] = new_func
+ return func
+
+ return register_handler
- return decorator
+ async def handle(self, event: dict) -> dict:
+ """Handle incoming Linear events.
+
+ Args:
+ event: The event payload from Linear
+
+ Returns:
+ Response dictionary
+ """
+ logger.info("[HANDLER] Handling Linear event")
+
+ try:
+ # Extract event type
+ event_type = event.get("type")
+ if not event_type:
+ logger.info("[HANDLER] No event type found in payload")
+ return {"message": "Event type not found"}
+
+ if event_type not in self.registered_handlers:
+ logger.info(f"[HANDLER] No handler found for event type: {event_type}")
+ return {"message": "Event handled successfully"}
+ else:
+ logger.info(f"[HANDLER] Handling event: {event_type}")
+ handler = self.registered_handlers[event_type]
+ result = handler(event)
+ if hasattr(result, "__await__"):
+ result = await result
+ return result
+
+ except Exception as e:
+ logger.exception(f"Error handling Linear event: {e}")
+ return {"error": f"Failed to handle event: {e!s}"}
diff --git a/src/codegen/extensions/events/slack.py b/src/codegen/extensions/events/slack.py
index 464d8e15b..5a25726af 100644
--- a/src/codegen/extensions/events/slack.py
+++ b/src/codegen/extensions/events/slack.py
@@ -1,57 +1,15 @@
import logging
import os
-from typing import Literal
-from pydantic import BaseModel, Field
from slack_sdk import WebClient
from codegen.extensions.events.interface import EventHandlerManagerProtocol
+from codegen.extensions.slack.types import SlackWebhookPayload
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
-class RichTextElement(BaseModel):
- type: str
- user_id: str | None = None
- text: str | None = None
-
-
-class RichTextSection(BaseModel):
- type: Literal["rich_text_section"]
- elements: list[RichTextElement]
-
-
-class Block(BaseModel):
- type: Literal["rich_text"]
- block_id: str
- elements: list[RichTextSection]
-
-
-class SlackEvent(BaseModel):
- user: str
- type: str
- ts: str
- client_msg_id: str
- text: str
- team: str
- blocks: list[Block]
- channel: str
- event_ts: str
-
-
-class SlackWebhookPayload(BaseModel):
- token: str | None = Field(None)
- team_id: str | None = Field(None)
- api_app_id: str | None = Field(None)
- event: SlackEvent | None = Field(None)
- type: str | None = Field(None)
- event_id: str | None = Field(None)
- event_time: int | None = Field(None)
- challenge: str | None = Field(None)
- subtype: str | None = Field(None)
-
-
class Slack(EventHandlerManagerProtocol):
_client: WebClient | None = None
@@ -68,21 +26,34 @@ def unsubscribe_all_handlers(self):
logger.info("[HANDLERS] Clearing all handlers")
self.registered_handlers.clear()
- def handle(self, event: SlackWebhookPayload):
+ async def handle(self, event_data: dict) -> dict:
+ """Handle incoming Slack events."""
logger.info("[HANDLER] Handling Slack event")
- if event.type == "url_verification":
- return {"challenge": event.challenge}
- elif event.type == "event_callback":
- event = event.event
- if event.type not in self.registered_handlers:
+
+ try:
+ # Validate and convert to SlackWebhookPayload
+ event = SlackWebhookPayload.model_validate(event_data)
+
+ if event.type == "url_verification":
+ return {"challenge": event.challenge}
+ elif event.type == "event_callback" and event.event:
+ if event.event.type not in self.registered_handlers:
+ logger.info(f"[HANDLER] No handler found for event type: {event.event.type}")
+ return {"message": "Event handled successfully"}
+ else:
+ handler = self.registered_handlers[event.event.type]
+ # Since the handler might be async, await it
+ result = handler(event.event)
+ if hasattr(result, "__await__"):
+ result = await result
+ return result
+ else:
logger.info(f"[HANDLER] No handler found for event type: {event.type}")
return {"message": "Event handled successfully"}
- else:
- handler = self.registered_handlers[event.type]
- return handler(event)
- else:
- logger.info(f"[HANDLER] No handler found for event type: {event.type}")
- return {"message": "Event handled successfully"}
+
+ except Exception as e:
+ logger.exception(f"Error handling Slack event: {e}")
+ return {"error": f"Failed to handle event: {e!s}"}
def event(self, event_name: str):
"""Decorator for registering a Slack event handler."""
@@ -93,10 +64,11 @@ def register_handler(func):
func_name = func.__qualname__
logger.info(f"[EVENT] Registering function {func_name} for {event_name}")
- def new_func(event):
- return func(self.client, event)
+ async def new_func(event):
+ # Just pass the event, handler can access client via app.slack.client
+ return await func(event)
self.registered_handlers[event_name] = new_func
- return new_func
+ return func
return register_handler
diff --git a/src/codegen/extensions/github/types/events/pull_request.py b/src/codegen/extensions/github/types/events/pull_request.py
index bd1d6fbec..7838e8e87 100644
--- a/src/codegen/extensions/github/types/events/pull_request.py
+++ b/src/codegen/extensions/github/types/events/pull_request.py
@@ -1,3 +1,5 @@
+from typing import Literal
+
from pydantic import BaseModel
from ..base import GitHubRepository, GitHubUser
@@ -8,16 +10,44 @@
from ..pull_request import PullRequest
+class User(BaseModel):
+ id: int
+ login: str
+
+
+class Label(BaseModel):
+ id: int
+ node_id: str
+ url: str
+ name: str
+ description: str | None = None
+ color: str
+ default: bool
+
+
+class SimplePullRequest(BaseModel):
+ id: int
+ number: int
+ state: str
+ locked: bool
+ title: str
+ user: User
+ body: str | None = None
+ labels: list[Label] = []
+ created_at: str
+ updated_at: str
+ draft: bool = False
+
+
class PullRequestLabeledEvent(BaseModel):
- action: str # Will be "labeled"
+ """Simplified version of the PR labeled event for testing"""
+
+ action: Literal["labeled"]
number: int
pull_request: PullRequest
- label: GitHubLabel
- repository: GitHubRepository
- organization: GitHubOrganization
- enterprise: GitHubEnterprise
- sender: GitHubUser
- installation: GitHubInstallation
+ label: Label
+ repository: dict # Simplified for now
+ sender: User
class PullRequestOpenedEvent(BaseModel):
diff --git a/src/codegen/extensions/github/types/pull_request.py b/src/codegen/extensions/github/types/pull_request.py
index 50228d3d6..c4b58eed6 100644
--- a/src/codegen/extensions/github/types/pull_request.py
+++ b/src/codegen/extensions/github/types/pull_request.py
@@ -1,9 +1,8 @@
-from typing import Optional
+from typing import Literal, Optional
from pydantic import BaseModel
from .base import GitHubRepository, GitHubUser
-from .label import GitHubLabel
class PullRequestRef(BaseModel):
@@ -25,6 +24,16 @@ class PullRequestLinks(BaseModel):
statuses: dict
+class Label(BaseModel):
+ id: int
+ node_id: str
+ url: str
+ name: str
+ description: str | None = None
+ color: str
+ default: bool
+
+
class PullRequest(BaseModel):
url: str
id: int
@@ -48,7 +57,7 @@ class PullRequest(BaseModel):
assignees: list[GitHubUser]
requested_reviewers: list[GitHubUser]
requested_teams: list[dict]
- labels: list[GitHubLabel]
+ labels: list[Label]
milestone: Optional[dict]
draft: bool
head: PullRequestRef
@@ -69,3 +78,12 @@ class PullRequest(BaseModel):
additions: int
deletions: int
changed_files: int
+
+
+class PullRequestLabeledEvent(BaseModel):
+ action: Literal["labeled"]
+ number: int
+ pull_request: PullRequest
+ label: Label
+ repository: dict # Simplified for now
+ sender: dict # Simplified for now
diff --git a/src/codegen/extensions/linear/linear_client.py b/src/codegen/extensions/linear/linear_client.py
index e27867dca..37291b806 100644
--- a/src/codegen/extensions/linear/linear_client.py
+++ b/src/codegen/extensions/linear/linear_client.py
@@ -4,37 +4,10 @@
from typing import Optional
import requests
-from pydantic import BaseModel
-
-logger = logging.getLogger(__name__)
-
-
-# --- TYPES
-
-
-class LinearUser(BaseModel):
- id: str
- name: str
+from codegen.extensions.linear.types import LinearComment, LinearIssue, LinearTeam, LinearUser
-class LinearTeam(BaseModel):
- """Represents a Linear team."""
-
- id: str
- name: str
- key: str
-
-
-class LinearComment(BaseModel):
- id: str
- body: str
- user: LinearUser | None = None
-
-
-class LinearIssue(BaseModel):
- id: str
- title: str
- description: str | None = None
+logger = logging.getLogger(__name__)
class LinearClient:
diff --git a/src/codegen/extensions/linear/types.py b/src/codegen/extensions/linear/types.py
new file mode 100644
index 000000000..fb9439399
--- /dev/null
+++ b/src/codegen/extensions/linear/types.py
@@ -0,0 +1,40 @@
+from pydantic import BaseModel
+
+
+class LinearUser(BaseModel):
+ id: str
+ name: str
+
+
+class LinearTeam(BaseModel):
+ """Represents a Linear team."""
+
+ id: str
+ name: str
+ key: str
+
+
+class LinearComment(BaseModel):
+ id: str
+ body: str
+ user: LinearUser | None = None
+
+
+class LinearIssue(BaseModel):
+ id: str
+ title: str
+ description: str | None = None
+ priority: int | None = None
+ team_id: str | None = None
+
+
+class LinearEvent(BaseModel):
+ """Represents a Linear webhook event."""
+
+ action: str # e.g. "create", "update", "remove"
+ type: str # e.g. "Issue", "Comment", "Project"
+ data: LinearIssue | LinearComment # The actual event data
+ url: str # URL to the resource in Linear
+ created_at: str | None = None # ISO timestamp
+ organization_id: str | None = None
+ team_id: str | None = None
diff --git a/src/codegen/extensions/slack/types.py b/src/codegen/extensions/slack/types.py
new file mode 100644
index 000000000..c9c56dc6e
--- /dev/null
+++ b/src/codegen/extensions/slack/types.py
@@ -0,0 +1,44 @@
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+class RichTextElement(BaseModel):
+ type: str
+ user_id: str | None = None
+ text: str | None = None
+
+
+class RichTextSection(BaseModel):
+ type: Literal["rich_text_section"]
+ elements: list[RichTextElement]
+
+
+class Block(BaseModel):
+ type: Literal["rich_text"]
+ block_id: str
+ elements: list[RichTextSection]
+
+
+class SlackEvent(BaseModel):
+ user: str
+ type: str
+ ts: str
+ client_msg_id: str | None = None
+ text: str
+ team: str | None = None
+ blocks: list[Block] | None = None
+ channel: str
+ event_ts: str
+
+
+class SlackWebhookPayload(BaseModel):
+ token: str | None = Field(None)
+ team_id: str | None = Field(None)
+ api_app_id: str | None = Field(None)
+ event: SlackEvent | None = Field(None)
+ type: str | None = Field(None)
+ event_id: str | None = Field(None)
+ event_time: int | None = Field(None)
+ challenge: str | None = Field(None)
+ subtype: str | None = Field(None)
diff --git a/test_events.py b/test_events.py
new file mode 100644
index 000000000..1e5644ab7
--- /dev/null
+++ b/test_events.py
@@ -0,0 +1,65 @@
+import asyncio
+import logging
+import sys
+from contextlib import asynccontextmanager
+
+from uvicorn.config import Config
+from uvicorn.server import Server
+
+from codegen.extensions.events.client import CodegenClient
+from codegen.extensions.events.codegen_app import CodegenApp
+
+# Configure logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def run_codegen_app():
+ """Run the CodegenApp server as a context manager"""
+ # Create the app
+ app = CodegenApp(name="test-app")
+
+ # Configure uvicorn
+ config = Config(app=app.app, host="127.0.0.1", port=8000, log_level="info")
+ server = Server(config=config)
+
+ # Start the server
+ server_task = asyncio.create_task(server.serve())
+ await asyncio.sleep(1) # Give the server a moment to start
+
+ try:
+ yield server
+ finally:
+ # Shutdown the server
+ server.should_exit = True
+ await server_task
+
+
+async def test_slack_events():
+ """Test sending various Slack events to the CodegenApp"""
+ async with run_codegen_app():
+ # Create a test client
+ client = CodegenClient()
+
+ try:
+ # Test sending a simple message
+ logger.info("Sending test message...")
+ response = await client.send_slack_message(text="Hello codegen!", channel="C123TEST", event_type="message")
+ logger.info(f"Response from simple message: {response}")
+
+ # Test sending an app mention
+ logger.info("Sending test app mention...")
+ response = await client.send_slack_message(text="<@U123BOT> help me with this code", channel="C123TEST", event_type="app_mention")
+ logger.info(f"Response from app mention: {response}")
+
+ finally:
+ client.close()
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(test_slack_events())
+ except KeyboardInterrupt:
+ logger.info("Test interrupted by user")
+ sys.exit(0)
diff --git a/tests/integration/extension/codegen_app/test_add_handlers.py b/tests/integration/extension/codegen_app/test_add_handlers.py
new file mode 100644
index 000000000..1359c9937
--- /dev/null
+++ b/tests/integration/extension/codegen_app/test_add_handlers.py
@@ -0,0 +1,90 @@
+import pytest
+from slack_sdk import WebClient
+
+from codegen.extensions.events.codegen_app import CodegenApp
+from codegen.extensions.events.slack import SlackEvent
+
+
+@pytest.fixture
+def app():
+ """Create a test CodegenApp instance"""
+ return CodegenApp(name="test-handlers")
+
+
+def test_register_slack_handler(app):
+ """Test registering a Slack event handler"""
+
+ @app.slack.event("message")
+ def handle_message(client: WebClient, event: SlackEvent):
+ return {"message": "Handled slack message"}
+
+ # Verify handler was registered
+ assert "message" in app.slack.registered_handlers
+ assert app.slack.registered_handlers["message"] is not None
+
+
+def test_register_github_handler(app):
+ """Test registering a GitHub event handler"""
+
+ @app.github.event("pull_request:opened")
+ def handle_pr(event: dict):
+ return {"message": "Handled PR"}
+
+ # Verify handler was registered
+ assert "pull_request:opened" in app.github.registered_handlers
+ assert app.github.registered_handlers["pull_request:opened"] is not None
+
+
+def test_register_linear_handler(app):
+ """Test registering a Linear event handler"""
+
+ @app.linear.event("Issue")
+ def handle_issue(event: dict):
+ return {"message": "Handled issue"}
+
+ # Verify handler was registered
+ handler = app.linear.registered_handlers[handle_issue.__qualname__]
+ assert handler.event_name == "Issue"
+ assert handler.handler_func is not None
+
+
+def test_register_multiple_handlers(app):
+ """Test registering multiple handlers across different providers"""
+
+ # Register Slack handler
+ @app.slack.event("message")
+ def handle_slack(client: WebClient, event: SlackEvent):
+ return {"message": "Handled slack"}
+
+ # Register GitHub handler
+ @app.github.event("push")
+ def handle_push(event: dict):
+ return {"message": "Handled push"}
+
+ # Register Linear handler
+ @app.linear.event("Issue")
+ def handle_linear(event: dict):
+ return {"message": "Handled linear"}
+
+ # Verify all handlers were registered
+ assert "message" in app.slack.registered_handlers
+ assert "push" in app.github.registered_handlers
+ assert handle_linear.__qualname__ in app.linear.registered_handlers
+
+ # Verify each handler is properly configured
+ assert app.slack.registered_handlers["message"] is not None
+ assert app.github.registered_handlers["push"] is not None
+ assert app.linear.registered_handlers[handle_linear.__qualname__].event_name == "Issue"
+
+
+def test_handler_registration_is_isolated(app):
+ """Test that handlers are properly isolated between provider types"""
+
+ @app.slack.event("message")
+ def handle_slack(client: WebClient, event: SlackEvent):
+ return {"message": "Handled slack"}
+
+ # Verify handler is only in Slack registry
+ assert "message" in app.slack.registered_handlers
+ assert "message" not in app.github.registered_handlers
+ assert not any(h.event_name == "message" for h in app.linear.registered_handlers.values())
diff --git a/tests/integration/extension/codegen_app/test_handler_logic.py b/tests/integration/extension/codegen_app/test_handler_logic.py
new file mode 100644
index 000000000..3571b7339
--- /dev/null
+++ b/tests/integration/extension/codegen_app/test_handler_logic.py
@@ -0,0 +1,299 @@
+import asyncio
+from contextlib import asynccontextmanager
+
+import pytest
+from uvicorn.config import Config
+from uvicorn.server import Server
+
+from codegen.extensions.events.client import CodegenClient
+from codegen.extensions.events.codegen_app import CodegenApp
+from codegen.extensions.github.types.events.pull_request import PullRequestLabeledEvent
+from codegen.extensions.linear.types import LinearEvent
+from codegen.extensions.slack.types import SlackEvent
+
+
+@pytest.fixture
+def app():
+ """Create a test CodegenApp instance"""
+ return CodegenApp(name="test-handlers")
+
+
+@pytest.fixture
+def app_with_handlers(app):
+ """Create a CodegenApp instance with pre-registered handlers"""
+
+ # Register Slack handler
+ @app.slack.event("app_mention")
+ async def handle_mention(event: SlackEvent):
+ return {"message": "Mentioned", "received_text": event.text}
+
+ # Register GitHub handler
+ @app.github.event("pull_request:labeled")
+ def handle_labeled(event: PullRequestLabeledEvent):
+ return {
+ "message": "PR labeled",
+ "pr_number": event.number,
+ "label": event.label.name,
+ "title": event.pull_request.title,
+ }
+
+ # Register Linear handler
+ @app.linear.event("Issue")
+ def handle_issue_created(event: LinearEvent):
+ return {
+ "message": "Issue created",
+ "issue_id": event.data.id,
+ "title": event.data.title,
+ }
+
+ return app
+
+
+@asynccontextmanager
+async def run_codegen_app(app: CodegenApp):
+ """Run the CodegenApp server as a context manager"""
+ # Configure uvicorn
+ config = Config(app=app.app, host="127.0.0.1", port=8000, log_level="info")
+ server = Server(config=config)
+
+ # Start the server
+ server_task = asyncio.create_task(server.serve())
+ await asyncio.sleep(1) # Give the server a moment to start
+
+ try:
+ yield server
+ finally:
+ # Shutdown the server
+ server.should_exit = True
+ await server_task
+
+
+@pytest.mark.asyncio
+async def test_server_slack_mention(app_with_handlers):
+ """Test sending a Slack mention through the actual server"""
+ async with run_codegen_app(app_with_handlers):
+ # Create a test client
+ client = CodegenClient()
+
+ try:
+ # Send test mention
+ response = await client.send_slack_message(text="<@U123BOT> help me with this code", channel="C123TEST", event_type="app_mention")
+
+ # Verify the response
+ assert response is not None
+ assert response["message"] == "Mentioned"
+ assert response["received_text"] == "<@U123BOT> help me with this code"
+
+ finally:
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_simulate_slack_mention(app_with_handlers):
+ """Test simulating a Slack app_mention event"""
+ # Create a test mention payload
+ payload = {
+ "token": "test_token",
+ "team_id": "T123456",
+ "api_app_id": "A123456",
+ "event": {
+ "type": "app_mention",
+ "user": "U123456",
+ "text": "<@U123BOT> help me with this code",
+ "ts": "1234567890.123456",
+ "channel": "C123456",
+ "event_ts": "1234567890.123456",
+ },
+ "type": "event_callback",
+ "event_id": "Ev123456",
+ "event_time": 1234567890,
+ }
+
+ # Simulate the event
+ response = await app_with_handlers.simulate_event(provider="slack", event_type="app_mention", payload=payload)
+
+ # Verify the response
+ assert response is not None
+ assert response["message"] == "Mentioned"
+ assert response["received_text"] == "<@U123BOT> help me with this code"
+
+
+@pytest.mark.asyncio
+async def test_simulate_unknown_provider(app_with_handlers):
+ """Test simulating an event with an unknown provider"""
+ with pytest.raises(ValueError) as exc_info:
+ await app_with_handlers.simulate_event(provider="unknown", event_type="test", payload={})
+
+ assert "Unknown provider" in str(exc_info.value)
+
+
+@pytest.mark.asyncio
+async def test_simulate_unregistered_event(app_with_handlers):
+ """Test simulating an event type that has no registered handler"""
+ payload = {"event": {"type": "unknown_event", "user": "U123456"}}
+
+ response = await app_with_handlers.simulate_event(provider="slack", event_type="unknown_event", payload=payload)
+
+ # Should return a default response for unhandled events
+ assert response["message"] == "Event handled successfully"
+
+
+@pytest.mark.asyncio
+async def test_simulate_github_pr_labeled(app_with_handlers):
+ """Test simulating a GitHub PR labeled event"""
+ # Create a test PR labeled payload
+ payload = {
+ "action": "labeled",
+ "number": 123,
+ "pull_request": {
+ "id": 12345,
+ "number": 123,
+ "state": "open",
+ "locked": False,
+ "title": "Test PR",
+ "user": {"id": 1, "login": "test-user"},
+ "body": "Test PR body",
+ "labels": [],
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z",
+ "draft": False,
+ },
+ "label": {"id": 1, "node_id": "123", "url": "https://api.github.com/repos/test/test/labels/bug", "name": "bug", "description": "Bug report", "color": "red", "default": False},
+ "repository": {"id": 1, "name": "test"},
+ "sender": {"id": 1, "login": "test-user"},
+ }
+
+ # Simulate the event
+ response = await app_with_handlers.simulate_event(provider="github", event_type="pull_request:labeled", payload=payload)
+
+ # Verify the response
+ assert response is not None
+ assert response["message"] == "PR labeled"
+ assert response["pr_number"] == 123
+ assert response["label"] == "bug"
+ assert response["title"] == "Test PR"
+
+
+@pytest.mark.asyncio
+async def test_server_github_pr_labeled(app_with_handlers):
+ """Test sending a GitHub PR labeled event through the actual server"""
+ async with run_codegen_app(app_with_handlers):
+ # Create a test client
+ client = CodegenClient()
+
+ try:
+ # Create test PR labeled payload
+ payload = {
+ "action": "labeled",
+ "number": 123,
+ "pull_request": {
+ "id": 12345,
+ "number": 123,
+ "node_id": "PR_123",
+ "state": "open",
+ "locked": False,
+ "title": "Test PR",
+ "user": {"id": 1, "login": "test-user", "node_id": "U_123", "type": "User"},
+ "body": "Test PR body",
+ "labels": [],
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z",
+ "draft": False,
+ "head": {
+ "label": "user:feature",
+ "ref": "feature",
+ "sha": "abc123",
+ "user": {"id": 1, "login": "test-user", "node_id": "U_123", "type": "User"},
+ "repo": {"id": 1, "name": "test", "node_id": "R_123", "full_name": "test/test", "private": False, "owner": {"id": 1, "login": "test-user", "node_id": "U_123", "type": "User"}},
+ },
+ "base": {
+ "label": "main",
+ "ref": "main",
+ "sha": "def456",
+ "user": {"id": 1, "login": "test-user", "node_id": "U_123", "type": "User"},
+ "repo": {"id": 1, "name": "test", "node_id": "R_123", "full_name": "test/test", "private": False, "owner": {"id": 1, "login": "test-user", "node_id": "U_123", "type": "User"}},
+ },
+ },
+ "label": {"id": 1, "node_id": "L_123", "url": "https://api.github.com/repos/test/test/labels/bug", "name": "bug", "description": "Bug report", "color": "red", "default": False},
+ "repository": {"id": 1, "name": "test", "node_id": "R_123", "full_name": "test/test", "private": False, "owner": {"id": 1, "login": "test-user", "node_id": "U_123", "type": "User"}},
+ "sender": {"id": 1, "login": "test-user", "node_id": "U_123", "type": "User"},
+ }
+
+ # Send test event
+ response = await client.send_github_event(
+ event_type="pull_request",
+ action="labeled",
+ payload=payload,
+ )
+
+ # Verify the response
+ assert response is not None
+ assert response["message"] == "PR labeled"
+ assert response["pr_number"] == 123
+ assert response["label"] == "bug"
+ assert response["title"] == "Test PR"
+
+ finally:
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_simulate_linear_issue_created(app_with_handlers):
+ """Test simulating a Linear issue created event"""
+ # Create a test issue created payload
+ payload = {
+ "action": "create",
+ "type": "Issue",
+ "data": {
+ "id": "abc-123",
+ "title": "Test Issue",
+ "description": "This is a test issue",
+ "priority": 1,
+ "teamId": "team-123",
+ },
+ "url": "https://linear.app/company/issue/ABC-123",
+ }
+
+ # Simulate the event
+ response = await app_with_handlers.simulate_event(provider="linear", event_type="Issue", payload=payload)
+
+ # Verify the response
+ assert response is not None
+ assert response["message"] == "Issue created"
+ assert response["issue_id"] == "abc-123"
+ assert response["title"] == "Test Issue"
+
+
+@pytest.mark.asyncio
+async def test_server_linear_issue_created(app_with_handlers):
+ """Test sending a Linear issue created event through the actual server"""
+ async with run_codegen_app(app_with_handlers):
+ # Create a test client
+ client = CodegenClient()
+
+ try:
+ # Create test issue created payload
+ payload = {
+ "action": "create",
+ "type": "Issue",
+ "data": {
+ "id": "abc-123",
+ "title": "Test Issue",
+ "description": "This is a test issue",
+ "priority": 1,
+ "teamId": "team-123",
+ },
+ "url": "https://linear.app/company/issue/ABC-123",
+ }
+
+ # Send test event
+ response = await client.send_linear_event(payload=payload)
+
+ # Verify the response
+ assert response is not None
+ assert response["message"] == "Issue created"
+ assert response["issue_id"] == "abc-123"
+ assert response["title"] == "Test Issue"
+
+ finally:
+ await client.close()
diff --git a/tests/integration/extension/codegen_app/test_server.ipynb b/tests/integration/extension/codegen_app/test_server.ipynb
new file mode 100644
index 000000000..b882894b2
--- /dev/null
+++ b/tests/integration/extension/codegen_app/test_server.ipynb
@@ -0,0 +1,184 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%load_ext autoreload\n",
+ "%autoreload 2\n",
+ "import asyncio\n",
+ "from contextlib import asynccontextmanager\n",
+ "\n",
+ "import pytest\n",
+ "from slack_sdk import WebClient\n",
+ "from uvicorn.config import Config\n",
+ "from uvicorn.server import Server\n",
+ "\n",
+ "from codegen.extensions.events.client import CodegenClient\n",
+ "from codegen.extensions.events.codegen_app import CodegenApp\n",
+ "from codegen.extensions.slack.types import SlackEvent"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "client = CodegenClient(base_url=\"https://codegen-sh-staging--codegen-test-fastapi-app.modal.run\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Slack"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "response = await client.send_slack_message(text=\"\", channel=\"C123TEST\", event_type=\"app_mention\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "response"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Github"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "payload = {\n",
+ " \"action\": \"labeled\",\n",
+ " \"number\": 123,\n",
+ " \"pull_request\": {\n",
+ " \"id\": 12345,\n",
+ " \"number\": 123,\n",
+ " \"node_id\": \"PR_123\",\n",
+ " \"state\": \"open\",\n",
+ " \"locked\": False,\n",
+ " \"title\": \"Test PR\",\n",
+ " \"user\": {\"id\": 1, \"login\": \"test-user\", \"node_id\": \"U_123\", \"type\": \"User\"},\n",
+ " \"body\": \"Test PR body\",\n",
+ " \"labels\": [],\n",
+ " \"created_at\": \"2024-01-01T00:00:00Z\",\n",
+ " \"updated_at\": \"2024-01-01T00:00:00Z\",\n",
+ " \"draft\": False,\n",
+ " \"head\": {\n",
+ " \"label\": \"user:feature\",\n",
+ " \"ref\": \"feature\",\n",
+ " \"sha\": \"abc123\",\n",
+ " \"user\": {\"id\": 1, \"login\": \"test-user\", \"node_id\": \"U_123\", \"type\": \"User\"},\n",
+ " \"repo\": {\"id\": 1, \"name\": \"test\", \"node_id\": \"R_123\", \"full_name\": \"test/test\", \"private\": False, \"owner\": {\"id\": 1, \"login\": \"test-user\", \"node_id\": \"U_123\", \"type\": \"User\"}},\n",
+ " },\n",
+ " \"base\": {\n",
+ " \"label\": \"main\",\n",
+ " \"ref\": \"main\",\n",
+ " \"sha\": \"def456\",\n",
+ " \"user\": {\"id\": 1, \"login\": \"test-user\", \"node_id\": \"U_123\", \"type\": \"User\"},\n",
+ " \"repo\": {\"id\": 1, \"name\": \"test\", \"node_id\": \"R_123\", \"full_name\": \"test/test\", \"private\": False, \"owner\": {\"id\": 1, \"login\": \"test-user\", \"node_id\": \"U_123\", \"type\": \"User\"}},\n",
+ " },\n",
+ " },\n",
+ " \"label\": {\"id\": 1, \"node_id\": \"L_123\", \"url\": \"https://api.github.com/repos/test/test/labels/bug\", \"name\": \"bug\", \"description\": \"Bug report\", \"color\": \"red\", \"default\": False},\n",
+ " \"repository\": {\"id\": 1, \"name\": \"test\", \"node_id\": \"R_123\", \"full_name\": \"test/test\", \"private\": False, \"owner\": {\"id\": 1, \"login\": \"test-user\", \"node_id\": \"U_123\", \"type\": \"User\"}},\n",
+ " \"sender\": {\"id\": 1, \"login\": \"test-user\", \"node_id\": \"U_123\", \"type\": \"User\"},\n",
+ "}\n",
+ "\n",
+ "# Send test event\n",
+ "response = await client.send_github_event(\n",
+ " event_type=\"pull_request\",\n",
+ " action=\"labeled\",\n",
+ " payload=payload,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Linear"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Create test issue created payload\n",
+ "payload = {\n",
+ " \"action\": \"create\",\n",
+ " \"type\": \"Issue\",\n",
+ " \"data\": {\n",
+ " \"id\": \"abc-123\",\n",
+ " \"title\": \"Test Issue\",\n",
+ " \"description\": \"This is a test issue\",\n",
+ " \"priority\": 1,\n",
+ " \"teamId\": \"team-123\",\n",
+ " },\n",
+ " \"url\": \"https://linear.app/company/issue/ABC-123\",\n",
+ "}\n",
+ "\n",
+ "# Send test event\n",
+ "response = await client.send_linear_event(payload=payload)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "response"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.0"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}