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 +}