diff --git a/src/codegen/extensions/langchain/sentry_tools.py b/src/codegen/extensions/langchain/sentry_tools.py new file mode 100644 index 000000000..7dfe46c81 --- /dev/null +++ b/src/codegen/extensions/langchain/sentry_tools.py @@ -0,0 +1,315 @@ +"""LangChain tools for Sentry integration.""" + +from typing import ClassVar, Optional + +from langchain_core.tools.base import BaseTool +from pydantic import BaseModel, Field + +from codegen.extensions.sentry.config import DEFAULT_ORGANIZATION_SLUG +from codegen.extensions.tools.sentry.tools import ( + view_sentry_event_details, + view_sentry_issue_details, + view_sentry_issues, +) +from codegen.sdk.core.codebase import Codebase + + +class ViewSentryIssuesInput(BaseModel): + """Input for viewing Sentry issues.""" + + organization_slug: str = Field( + default=DEFAULT_ORGANIZATION_SLUG, + description="Organization slug (e.g., 'codegen-sh', 'ramp')", + ) + project_slug: Optional[str] = Field( + default=None, + description="Optional project slug to filter by (e.g., 'codegen', 'api')", + ) + query: Optional[str] = Field( + default=None, + description="Optional search query to filter issues", + ) + status: str = Field( + default="unresolved", + description="Status filter (e.g., 'resolved', 'unresolved', 'ignored')", + ) + limit: int = Field( + default=20, + description="Maximum number of issues to return", + ) + cursor: Optional[str] = Field( + default=None, + description="Pagination cursor for fetching the next page of results", + ) + + +class ViewSentryIssueDetailsInput(BaseModel): + """Input for viewing a specific Sentry issue.""" + + issue_id: str = Field( + ..., + description="ID of the issue to view", + ) + organization_slug: str = Field( + default=DEFAULT_ORGANIZATION_SLUG, + description="Organization slug (e.g., 'codegen-sh', 'ramp')", + ) + limit: int = Field( + default=10, + description="Maximum number of events to return", + ) + cursor: Optional[str] = Field( + default=None, + description="Pagination cursor for fetching the next page of events", + ) + + +class ViewSentryEventDetailsInput(BaseModel): + """Input for viewing a specific Sentry event.""" + + event_id: str = Field( + ..., + description="ID of the event to view", + ) + organization_slug: str = Field( + default=DEFAULT_ORGANIZATION_SLUG, + description="Organization slug (e.g., 'codegen-sh', 'ramp')", + ) + project_slug: str = Field( + default="codegen", + description="Project slug (e.g., 'codegen', 'api')", + ) + + +class ViewSentryIssuesTool(BaseTool): + """Tool for viewing Sentry issues.""" + + name: ClassVar[str] = "view_sentry_issues" + description: ClassVar[str] = """ + View a list of Sentry issues for an organization or project. + + This tool allows you to retrieve and filter Sentry issues by organization, project, status, and search query. + Results are paginated, and you can use the cursor parameter to fetch additional pages. + """ + args_schema: ClassVar[type[BaseModel]] = ViewSentryIssuesInput + codebase: Codebase = Field(exclude=True) + + def __init__(self, codebase: Codebase) -> None: + super().__init__(codebase=codebase) + + def _run( + self, + organization_slug: str = DEFAULT_ORGANIZATION_SLUG, + project_slug: Optional[str] = None, + query: Optional[str] = None, + status: str = "unresolved", + limit: int = 20, + cursor: Optional[str] = None, + ) -> str: + result = view_sentry_issues( + codebase=self.codebase, + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + status=status, + limit=limit, + cursor=cursor, + ) + return result.render() + + +class ViewSentryIssueDetailsTool(BaseTool): + """Tool for viewing details of a specific Sentry issue.""" + + name: ClassVar[str] = "view_sentry_issue" + description: ClassVar[str] = """ + View detailed information about a specific Sentry issue, including its events. + + This tool retrieves comprehensive information about a Sentry issue, including its metadata and associated events. + Events are paginated, and you can use the cursor parameter to fetch additional pages. + """ + args_schema: ClassVar[type[BaseModel]] = ViewSentryIssueDetailsInput + codebase: Codebase = Field(exclude=True) + + def __init__(self, codebase: Codebase) -> None: + super().__init__(codebase=codebase) + + def _run( + self, + issue_id: str, + organization_slug: str = DEFAULT_ORGANIZATION_SLUG, + limit: int = 10, + cursor: Optional[str] = None, + ) -> str: + result = view_sentry_issue_details( + codebase=self.codebase, + issue_id=issue_id, + organization_slug=organization_slug, + limit=limit, + cursor=cursor, + ) + return result.render() + + +class ViewSentryEventDetailsTool(BaseTool): + """Tool for viewing details of a specific Sentry event.""" + + name: ClassVar[str] = "view_sentry_event" + description: ClassVar[str] = """ + View detailed information about a specific Sentry event. + + This tool retrieves comprehensive information about a Sentry event, including its metadata, tags, user information, + and stack trace (if available). + """ + args_schema: ClassVar[type[BaseModel]] = ViewSentryEventDetailsInput + codebase: Codebase = Field(exclude=True) + + def __init__(self, codebase: Codebase) -> None: + super().__init__(codebase=codebase) + + def _run( + self, + event_id: str, + organization_slug: str = DEFAULT_ORGANIZATION_SLUG, + project_slug: str = "codegen", + ) -> str: + result = view_sentry_event_details( + codebase=self.codebase, + event_id=event_id, + organization_slug=organization_slug, + project_slug=project_slug, + ) + return result.render() + + +class ViewSentryToolInput(BaseModel): + """Input for the combined ViewSentryTool.""" + + action: str = Field( + default="view_issues", + description="Action to perform: 'view_issues', 'view_issue', or 'view_event'", + ) + organization_slug: str = Field( + default=DEFAULT_ORGANIZATION_SLUG, + description="Organization slug (e.g., 'codegen-sh', 'ramp')", + ) + project_slug: Optional[str] = Field( + default=None, + description="Project slug (e.g., 'codegen', 'api'). Required for 'view_event' action.", + ) + issue_id: Optional[str] = Field( + default=None, + description="ID of the issue to view. Required for 'view_issue' action.", + ) + event_id: Optional[str] = Field( + default=None, + description="ID of the event to view. Required for 'view_event' action.", + ) + query: Optional[str] = Field( + default=None, + description="Search query for filtering issues. Only used with 'view_issues' action.", + ) + status: str = Field( + default="unresolved", + description="Status filter for issues (e.g., 'resolved', 'unresolved'). Only used with 'view_issues' action.", + ) + limit: int = Field( + default=20, + description="Maximum number of results to return. Used with 'view_issues' and 'view_issue' actions.", + ) + cursor: Optional[str] = Field( + default=None, + description="Pagination cursor for fetching the next page of results. Used with 'view_issues' and 'view_issue' actions.", + ) + + +class ViewSentryTool(BaseTool): + """Combined tool for viewing Sentry issues and events.""" + + name: ClassVar[str] = "view_sentry" + description: ClassVar[str] = """ + View Sentry issues and events. + + This tool allows you to: + 1. View a list of Sentry issues for an organization or project + 2. View details of a specific Sentry issue, including its events + 3. View details of a specific Sentry event, including stack traces + + Specify the action parameter to choose which operation to perform: + - 'view_issues': List issues (default) + - 'view_issue': View a specific issue (requires issue_id) + - 'view_event': View a specific event (requires event_id and project_slug) + """ + args_schema: ClassVar[type[BaseModel]] = ViewSentryToolInput + codebase: Codebase = Field(exclude=True) + + def __init__(self, codebase: Codebase) -> None: + super().__init__(codebase=codebase) + + def _run( + self, + action: str = "view_issues", + organization_slug: str = DEFAULT_ORGANIZATION_SLUG, + project_slug: Optional[str] = None, + issue_id: Optional[str] = None, + event_id: Optional[str] = None, + query: Optional[str] = None, + status: str = "unresolved", + limit: int = 20, + cursor: Optional[str] = None, + ) -> str: + if action == "view_issues": + result = view_sentry_issues( + codebase=self.codebase, + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + status=status, + limit=limit, + cursor=cursor, + ) + elif action == "view_issue": + if not issue_id: + return "Error: Missing required parameter 'issue_id' for action 'view_issue'" + + result = view_sentry_issue_details( + codebase=self.codebase, + issue_id=issue_id, + organization_slug=organization_slug, + limit=limit, + cursor=cursor, + ) + elif action == "view_event": + if not event_id: + return "Error: Missing required parameter 'event_id' for action 'view_event'" + + if not project_slug: + return "Error: Missing required parameter 'project_slug' for action 'view_event'" + + result = view_sentry_event_details( + codebase=self.codebase, + event_id=event_id, + organization_slug=organization_slug, + project_slug=project_slug, + ) + else: + return f"Error: Unknown action '{action}'. Supported actions: 'view_issues', 'view_issue', 'view_event'" + + return result.render() + + +def get_sentry_tools(codebase: Codebase) -> list[BaseTool]: + """Get all Sentry tools initialized with a codebase. + + Args: + codebase: The codebase to operate on + + Returns: + List of initialized Sentry tools + """ + return [ + ViewSentryIssuesTool(codebase), + ViewSentryIssueDetailsTool(codebase), + ViewSentryEventDetailsTool(codebase), + ViewSentryTool(codebase), + ] diff --git a/src/codegen/extensions/langchain/tools.py b/src/codegen/extensions/langchain/tools.py index 877b59f05..3596f63a5 100644 --- a/src/codegen/extensions/langchain/tools.py +++ b/src/codegen/extensions/langchain/tools.py @@ -5,6 +5,7 @@ from langchain_core.tools.base import BaseTool from pydantic import BaseModel, Field +from codegen.extensions.langchain.sentry_tools import get_sentry_tools from codegen.extensions.linear.linear_client import LinearClient from codegen.extensions.tools.bash import run_bash_command from codegen.extensions.tools.github.checkout_pr import checkout_pr @@ -837,7 +838,7 @@ def get_workspace_tools(codebase: Codebase) -> list["BaseTool"]: Returns: List of initialized Langchain tools """ - return [ + tools = [ CommitTool(codebase), CreateFileTool(codebase), DeleteFileTool(codebase), @@ -870,6 +871,11 @@ def get_workspace_tools(codebase: Codebase) -> list["BaseTool"]: LinearGetTeamsTool(codebase), ] + # Add Sentry tools + tools.extend(get_sentry_tools(codebase)) + + return tools + class ReplacementEditInput(BaseModel): """Input for replacement editing.""" diff --git a/src/codegen/extensions/sentry/README.md b/src/codegen/extensions/sentry/README.md new file mode 100644 index 000000000..eded30599 --- /dev/null +++ b/src/codegen/extensions/sentry/README.md @@ -0,0 +1,138 @@ +# Sentry Integration for Codegen + +This module provides integration with Sentry, allowing you to view Sentry issues and events directly from Codegen. + +## Features + +- View Sentry issues across organizations and projects +- Filter issues by query parameters and status +- View detailed information about specific issues +- View events associated with issues +- View detailed information about specific events, including stack traces + +## Setup + +### Environment Variables + +The following environment variables need to be set: + +- `SENTRY_AUTH_TOKEN`: Your Sentry auth token +- `SENTRY_CODEGEN_INSTALLATION_UUID`: The installation UUID for the Codegen Sentry account +- `SENTRY_RAMP_INSTALLATION_UUID`: The installation UUID for the Ramp Sentry account (if applicable) + +### Usage + +#### Direct Usage + +```python +from codegen.extensions.tools.sentry.tools import ( + view_sentry_issues, + view_sentry_issue_details, + view_sentry_event_details, +) +from codegen.sdk.core.codebase import Codebase + +# Initialize a codebase +codebase = Codebase.from_local("./") + +# View issues +issues_result = view_sentry_issues( + codebase=codebase, + organization_slug="codegen-sh", + status="unresolved", + limit=5, +) +print(issues_result.render()) + +# View issue details +issue_result = view_sentry_issue_details( + codebase=codebase, + issue_id="ISSUE_ID", + organization_slug="codegen-sh", +) +print(issue_result.render()) + +# View event details +event_result = view_sentry_event_details( + codebase=codebase, + event_id="EVENT_ID", + organization_slug="codegen-sh", + project_slug="codegen", +) +print(event_result.render()) +``` + +#### Using LangChain Tools + +```python +from codegen.extensions.langchain.sentry_tools import ( + ViewSentryIssuesTool, + ViewSentryIssueDetailsTool, + ViewSentryEventDetailsTool, + ViewSentryTool, +) +from codegen.sdk.core.codebase import Codebase + +# Initialize a codebase +codebase = Codebase.from_local("./") + +# Initialize the tools +view_issues_tool = ViewSentryIssuesTool(codebase) +view_issue_tool = ViewSentryIssueDetailsTool(codebase) +view_event_tool = ViewSentryEventDetailsTool(codebase) +view_sentry_tool = ViewSentryTool(codebase) + +# Use the tools +result = view_sentry_tool._run( + action="view_issues", + organization_slug="codegen-sh", + status="unresolved", + limit=5, +) +print(result) +``` + +#### Using the Combined Tool + +The `ViewSentryTool` is a combined tool that can perform all Sentry operations: + +```python +from codegen.extensions.langchain.sentry_tools import ViewSentryTool +from codegen.sdk.core.codebase import Codebase + +# Initialize a codebase +codebase = Codebase.from_local("./") + +# Initialize the tool +view_sentry_tool = ViewSentryTool(codebase) + +# View issues +result = view_sentry_tool._run( + action="view_issues", + organization_slug="codegen-sh", + status="unresolved", + limit=5, +) +print(result) + +# View issue details +result = view_sentry_tool._run( + action="view_issue", + issue_id="ISSUE_ID", + organization_slug="codegen-sh", +) +print(result) + +# View event details +result = view_sentry_tool._run( + action="view_event", + event_id="EVENT_ID", + organization_slug="codegen-sh", + project_slug="codegen", +) +print(result) +``` + +## Example Notebook + +See the [example notebook](./example.ipynb) for a complete example of using the Sentry integration. diff --git a/src/codegen/extensions/sentry/__init__.py b/src/codegen/extensions/sentry/__init__.py new file mode 100644 index 000000000..d9d888cda --- /dev/null +++ b/src/codegen/extensions/sentry/__init__.py @@ -0,0 +1 @@ +"""Sentry integration for Codegen.""" diff --git a/src/codegen/extensions/sentry/config.py b/src/codegen/extensions/sentry/config.py new file mode 100644 index 000000000..7cb058e6d --- /dev/null +++ b/src/codegen/extensions/sentry/config.py @@ -0,0 +1,56 @@ +"""Configuration for Sentry integration.""" + +import os +from typing import Optional + +# Environment variable names +SENTRY_AUTH_TOKEN_ENV = "SENTRY_AUTH_TOKEN" +SENTRY_CODEGEN_INSTALLATION_UUID_ENV = "SENTRY_CODEGEN_INSTALLATION_UUID" +SENTRY_RAMP_INSTALLATION_UUID_ENV = "SENTRY_RAMP_INSTALLATION_UUID" + +# Default values +DEFAULT_ORGANIZATION_SLUG = "codegen-sh" + + +def get_sentry_auth_token() -> Optional[str]: + """Get the Sentry auth token from environment variables. + + Returns: + The Sentry auth token, or None if not set. + """ + return os.environ.get(SENTRY_AUTH_TOKEN_ENV) + + +def get_installation_uuid(organization_slug: str) -> Optional[str]: + """Get the Sentry installation UUID for the specified organization. + + Args: + organization_slug: The organization slug to get the installation UUID for. + + Returns: + The Sentry installation UUID, or None if not set. + """ + if organization_slug == "codegen-sh": + return os.environ.get(SENTRY_CODEGEN_INSTALLATION_UUID_ENV) + elif organization_slug == "ramp": + return os.environ.get(SENTRY_RAMP_INSTALLATION_UUID_ENV) + return None + + +def get_available_organizations() -> dict[str, Optional[str]]: + """Get a dictionary of available organizations and their installation UUIDs. + + Returns: + A dictionary mapping organization slugs to installation UUIDs. + """ + orgs = {} + + codegen_uuid = os.environ.get(SENTRY_CODEGEN_INSTALLATION_UUID_ENV) + if codegen_uuid: + orgs["codegen-sh"] = codegen_uuid + + ramp_uuid = os.environ.get(SENTRY_RAMP_INSTALLATION_UUID_ENV) + if ramp_uuid: + orgs["ramp"] = ramp_uuid + + return orgs diff --git a/src/codegen/extensions/sentry/example.ipynb b/src/codegen/extensions/sentry/example.ipynb new file mode 100644 index 000000000..01d4ba871 --- /dev/null +++ b/src/codegen/extensions/sentry/example.ipynb @@ -0,0 +1,226 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sentry Integration Example\n", + "\n", + "This notebook demonstrates how to use the Sentry integration in Codegen." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First, let's import the necessary modules and set up the environment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from codegen.extensions.sentry.sentry_client import SentryClient\n", + "from codegen.extensions.sentry.config import get_available_organizations\n", + "from codegen.extensions.tools.sentry.tools import (\n", + " view_sentry_issues,\n", + " view_sentry_issue_details,\n", + " view_sentry_event_details,\n", + ")\n", + "from codegen.sdk.core.codebase import Codebase" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Environment Variables\n", + "\n", + "Make sure you have the following environment variables set:\n", + "\n", + "- `SENTRY_AUTH_TOKEN`: Your Sentry auth token\n", + "- `SENTRY_CODEGEN_INSTALLATION_UUID`: The installation UUID for the Codegen Sentry account\n", + "- `SENTRY_RAMP_INSTALLATION_UUID`: The installation UUID for the Ramp Sentry account (if applicable)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check available organizations\n", + "available_orgs = get_available_organizations()\n", + "print(f\"Available organizations: {available_orgs}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Initialize Codebase\n", + "\n", + "We need a Codebase instance to use the tools." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize a codebase (can be any codebase)\n", + "codebase = Codebase.from_local(\"./\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## View Sentry Issues\n", + "\n", + "Let's view some Sentry issues for the Codegen organization." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# View unresolved issues for the Codegen organization\n", + "issues_result = view_sentry_issues(\n", + " codebase=codebase,\n", + " organization_slug=\"codegen-sh\",\n", + " status=\"unresolved\",\n", + " limit=5,\n", + ")\n", + "\n", + "print(issues_result.render())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## View Issue Details\n", + "\n", + "Now, let's view details for a specific issue. Replace `ISSUE_ID` with an actual issue ID from the previous step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# View details for a specific issue\n", + "# Replace ISSUE_ID with an actual issue ID\n", + "issue_id = \"ISSUE_ID\"\n", + "\n", + "issue_result = view_sentry_issue_details(\n", + " codebase=codebase,\n", + " issue_id=issue_id,\n", + " organization_slug=\"codegen-sh\",\n", + " limit=3,\n", + ")\n", + "\n", + "print(issue_result.render())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## View Event Details\n", + "\n", + "Finally, let's view details for a specific event. Replace `EVENT_ID` with an actual event ID from the previous step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# View details for a specific event\n", + "# Replace EVENT_ID with an actual event ID\n", + "event_id = \"EVENT_ID\"\n", + "\n", + "event_result = view_sentry_event_details(\n", + " codebase=codebase,\n", + " event_id=event_id,\n", + " organization_slug=\"codegen-sh\",\n", + " project_slug=\"codegen\",\n", + ")\n", + "\n", + "print(event_result.render())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Using the LangChain Tools\n", + "\n", + "You can also use the LangChain tools directly in an agent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from codegen.extensions.langchain.sentry_tools import (\n", + " ViewSentryIssuesTool,\n", + " ViewSentryIssueDetailsTool,\n", + " ViewSentryEventDetailsTool,\n", + " ViewSentryTool,\n", + ")\n", + "\n", + "# Initialize the tools\n", + "view_issues_tool = ViewSentryIssuesTool(codebase)\n", + "view_issue_tool = ViewSentryIssueDetailsTool(codebase)\n", + "view_event_tool = ViewSentryEventDetailsTool(codebase)\n", + "view_sentry_tool = ViewSentryTool(codebase)\n", + "\n", + "# Example usage of the combined tool\n", + "result = view_sentry_tool._run(\n", + " action=\"view_issues\",\n", + " organization_slug=\"codegen-sh\",\n", + " status=\"unresolved\",\n", + " limit=5,\n", + ")\n", + "\n", + "print(result)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/codegen/extensions/sentry/sentry_client.py b/src/codegen/extensions/sentry/sentry_client.py new file mode 100644 index 000000000..b773e3190 --- /dev/null +++ b/src/codegen/extensions/sentry/sentry_client.py @@ -0,0 +1,233 @@ +"""Sentry API client for interacting with the Sentry API.""" + +import os +from typing import Any, Optional + +import requests +from pydantic import BaseModel, Field + + +class SentryIssue(BaseModel): + """Sentry issue model.""" + + id: str = Field(..., description="Issue ID") + shortId: str = Field(..., description="Short ID (e.g., PROJECT-123)") + title: str = Field(..., description="Issue title") + culprit: str = Field(..., description="Culprit") + status: str = Field(..., description="Issue status") + level: str = Field(..., description="Issue level (e.g., error, warning)") + project: dict[str, Any] = Field(..., description="Project information") + count: int = Field(..., description="Number of events") + userCount: int = Field(..., description="Number of users affected") + firstSeen: str = Field(..., description="First seen timestamp") + lastSeen: str = Field(..., description="Last seen timestamp") + permalink: str = Field(..., description="Permalink to the issue") + + +class SentryEvent(BaseModel): + """Sentry event model.""" + + id: str = Field(..., description="Event ID") + eventID: str = Field(..., description="Event ID") + title: str = Field(..., description="Event title") + message: Optional[str] = Field(None, description="Event message") + dateCreated: str = Field(..., description="Date created") + user: Optional[dict[str, Any]] = Field(None, description="User information") + tags: list[dict[str, str]] = Field(..., description="Event tags") + entries: list[dict[str, Any]] = Field(..., description="Event entries") + contexts: dict[str, Any] = Field(..., description="Event contexts") + sdk: dict[str, Any] = Field(..., description="SDK information") + metadata: dict[str, Any] = Field(..., description="Event metadata") + + +class SentryOrganization(BaseModel): + """Sentry organization model.""" + + id: str = Field(..., description="Organization ID") + slug: str = Field(..., description="Organization slug") + name: str = Field(..., description="Organization name") + dateCreated: str = Field(..., description="Date created") + isEarlyAdopter: bool = Field(..., description="Is early adopter") + require2FA: bool = Field(..., description="Requires 2FA") + status: dict[str, Any] = Field(..., description="Organization status") + + +class SentryProject(BaseModel): + """Sentry project model.""" + + id: str = Field(..., description="Project ID") + slug: str = Field(..., description="Project slug") + name: str = Field(..., description="Project name") + platform: Optional[str] = Field(None, description="Project platform") + dateCreated: str = Field(..., description="Date created") + isBookmarked: bool = Field(..., description="Is bookmarked") + isMember: bool = Field(..., description="Is member") + features: list[str] = Field(..., description="Project features") + firstEvent: Optional[str] = Field(None, description="First event timestamp") + firstTransactionEvent: Optional[bool] = Field(None, description="Has first transaction event") + access: list[str] = Field(..., description="Access levels") + hasAccess: bool = Field(..., description="Has access") + hasMinifiedStackTrace: bool = Field(..., description="Has minified stack trace") + hasMonitors: bool = Field(..., description="Has monitors") + hasProfiles: bool = Field(..., description="Has profiles") + hasReplays: bool = Field(..., description="Has replays") + hasSessions: bool = Field(..., description="Has sessions") + isInternal: bool = Field(..., description="Is internal") + isPublic: bool = Field(..., description="Is public") + organization: dict[str, Any] = Field(..., description="Organization information") + + +class SentryClient: + """Client for interacting with the Sentry API.""" + + def __init__(self, auth_token: Optional[str] = None, installation_uuid: Optional[str] = None): + """Initialize the Sentry API client. + + Args: + auth_token: Sentry auth token. If not provided, will look for SENTRY_AUTH_TOKEN env var. + installation_uuid: Sentry installation UUID. If not provided, will look for SENTRY_INSTALLATION_UUID env var. + """ + self.auth_token = auth_token or os.environ.get("SENTRY_AUTH_TOKEN") + self.installation_uuid = installation_uuid or os.environ.get("SENTRY_INSTALLATION_UUID") + + if not self.auth_token: + msg = "Sentry auth token not provided. Set SENTRY_AUTH_TOKEN environment variable." + raise ValueError(msg) + + self.base_url = "https://sentry.io/api/0" + self.headers = { + "Authorization": f"Bearer {self.auth_token}", + "Content-Type": "application/json", + } + + def _make_request(self, method: str, endpoint: str, params: Optional[dict[str, Any]] = None, data: Optional[dict[str, Any]] = None) -> dict[str, Any]: + """Make a request to the Sentry API. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint + params: Query parameters + data: Request body data + + Returns: + Response data as a dictionary + """ + url = f"{self.base_url}{endpoint}" + response = requests.request(method, url, headers=self.headers, params=params, json=data) + response.raise_for_status() + return response.json() + + def get_organizations(self) -> list[SentryOrganization]: + """Get a list of organizations the user has access to. + + Returns: + List of organizations + """ + data = self._make_request("GET", "/organizations/") + return [SentryOrganization(**org) for org in data] + + def get_projects(self, organization_slug: str) -> list[SentryProject]: + """Get a list of projects for an organization. + + Args: + organization_slug: Organization slug + + Returns: + List of projects + """ + data = self._make_request("GET", f"/organizations/{organization_slug}/projects/") + return [SentryProject(**project) for project in data] + + def get_issues( + self, + organization_slug: str, + project_slug: Optional[str] = None, + query: Optional[str] = None, + status: Optional[str] = None, + limit: int = 100, + cursor: Optional[str] = None, + ) -> dict[str, Any]: + """Get a list of issues for an organization or project. + + Args: + organization_slug: Organization slug + project_slug: Optional project slug to filter by + query: Optional search query + status: Optional status filter (e.g., "resolved", "unresolved") + limit: Maximum number of issues to return + cursor: Pagination cursor + + Returns: + Dictionary containing issues and pagination info + """ + params = { + "limit": limit, + } + + if project_slug: + params["project"] = project_slug + + if query: + params["query"] = query + + if status: + params["status"] = status + + if cursor: + params["cursor"] = cursor + + return self._make_request("GET", f"/organizations/{organization_slug}/issues/", params=params) + + def get_issue_details(self, issue_id: str, organization_slug: str) -> SentryIssue: + """Get details for a specific issue. + + Args: + issue_id: Issue ID + organization_slug: Organization slug + + Returns: + Issue details + """ + data = self._make_request("GET", f"/organizations/{organization_slug}/issues/{issue_id}/") + return SentryIssue(**data) + + def get_issue_events( + self, + issue_id: str, + organization_slug: str, + limit: int = 100, + cursor: Optional[str] = None, + ) -> dict[str, Any]: + """Get events for a specific issue. + + Args: + issue_id: Issue ID + organization_slug: Organization slug + limit: Maximum number of events to return + cursor: Pagination cursor + + Returns: + Dictionary containing events and pagination info + """ + params = { + "limit": limit, + } + + if cursor: + params["cursor"] = cursor + + return self._make_request("GET", f"/organizations/{organization_slug}/issues/{issue_id}/events/", params=params) + + def get_event_details(self, event_id: str, organization_slug: str, project_slug: str) -> SentryEvent: + """Get details for a specific event. + + Args: + event_id: Event ID + organization_slug: Organization slug + project_slug: Project slug + + Returns: + Event details + """ + data = self._make_request("GET", f"/projects/{organization_slug}/{project_slug}/events/{event_id}/") + return SentryEvent(**data) diff --git a/src/codegen/extensions/tools/sentry/__init__.py b/src/codegen/extensions/tools/sentry/__init__.py new file mode 100644 index 000000000..9fd490917 --- /dev/null +++ b/src/codegen/extensions/tools/sentry/__init__.py @@ -0,0 +1 @@ +"""Sentry tools for Codegen.""" diff --git a/src/codegen/extensions/tools/sentry/sentry_tool.py b/src/codegen/extensions/tools/sentry/sentry_tool.py new file mode 100644 index 000000000..397d98c2e --- /dev/null +++ b/src/codegen/extensions/tools/sentry/sentry_tool.py @@ -0,0 +1,111 @@ +"""Sentry tool for viewing Sentry issues and events.""" + +from typing import Any + +from codegen.extensions.tools.sentry.tools import ( + view_sentry_event_details, + view_sentry_issue_details, + view_sentry_issues, +) +from codegen.sdk.core.codebase import Codebase + + +async def handle_sentry_tool(codebase: Codebase, tool_call: dict[str, Any]) -> dict[str, Any]: + """Handle a Sentry tool call. + + Args: + codebase: The codebase to operate on + tool_call: The tool call parameters + + Returns: + The tool result + """ + action = tool_call.get("action", "view_issues") + args = tool_call.get("args", {}) + + if action == "view_issues": + organization_slug = args.get("organization_slug", "codegen-sh") + project_slug = args.get("project_slug") + query = args.get("query") + status = args.get("status", "unresolved") + limit = args.get("limit", 20) + cursor = args.get("cursor") + + result = view_sentry_issues( + codebase=codebase, + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + status=status, + limit=limit, + cursor=cursor, + ) + + elif action == "view_issue": + issue_id = args.get("issue_id") + if not issue_id: + return { + "status": "error", + "error": "Missing required parameter: issue_id", + } + + organization_slug = args.get("organization_slug", "codegen-sh") + limit = args.get("limit", 10) + cursor = args.get("cursor") + + result = view_sentry_issue_details( + codebase=codebase, + issue_id=issue_id, + organization_slug=organization_slug, + limit=limit, + cursor=cursor, + ) + + elif action == "view_event": + event_id = args.get("event_id") + if not event_id: + return { + "status": "error", + "error": "Missing required parameter: event_id", + } + + organization_slug = args.get("organization_slug", "codegen-sh") + project_slug = args.get("project_slug", "codegen") + + result = view_sentry_event_details( + codebase=codebase, + event_id=event_id, + organization_slug=organization_slug, + project_slug=project_slug, + ) + + else: + return { + "status": "error", + "error": f"Unknown action: {action}. Supported actions: view_issues, view_issue, view_event", + } + + return { + "status": result.status, + "error": result.error if hasattr(result, "error") and result.error else None, + "result": result.render(), + } + + +# Export the tool for use in other modules +sentry_tool = { + "name": "ViewSentryTool", + "description": """View Sentry issues and events. + + This tool allows you to: + 1. View a list of Sentry issues for an organization or project + 2. View details of a specific Sentry issue, including its events + 3. View details of a specific Sentry event, including stack traces + + Examples: + - To view issues: {"action": "view_issues", "args": {"organization_slug": "codegen-sh", "project_slug": "codegen", "status": "unresolved"}} + - To view an issue: {"action": "view_issue", "args": {"issue_id": "123456", "organization_slug": "codegen-sh"}} + - To view an event: {"action": "view_event", "args": {"event_id": "abcdef1234567890", "organization_slug": "codegen-sh", "project_slug": "codegen"}} + """, + "handler": handle_sentry_tool, +} diff --git a/src/codegen/extensions/tools/sentry/tools.py b/src/codegen/extensions/tools/sentry/tools.py new file mode 100644 index 000000000..307b20942 --- /dev/null +++ b/src/codegen/extensions/tools/sentry/tools.py @@ -0,0 +1,467 @@ +"""Tools for interacting with Sentry.""" + +from typing import Any, ClassVar, Dict, List, Optional + +from pydantic import Field + +from codegen.extensions.sentry.config import ( + DEFAULT_ORGANIZATION_SLUG, + get_available_organizations, + get_installation_uuid, + get_sentry_auth_token, +) +from codegen.extensions.sentry.sentry_client import SentryClient +from codegen.sdk.core.codebase import Codebase + +from ..observation import Observation + + +class SentryIssuesObservation(Observation): + """Response from viewing Sentry issues.""" + + organization_slug: str = Field(description="Organization slug") + project_slug: Optional[str] = Field(None, description="Project slug (if specified)") + query: Optional[str] = Field(None, description="Search query (if specified)") + status: str = Field(description="Status filter (if specified)") + issues: List[Dict[str, Any]] = Field(description="List of issues") + has_more: bool = Field(description="Whether there are more issues to fetch") + next_cursor: Optional[str] = Field(None, description="Cursor for fetching the next page") + + str_template: ClassVar[str] = "Found {issue_count} issues in {organization_slug}" + + def _get_details(self) -> Dict[str, Any]: + """Get details for string representation.""" + return { + "issue_count": len(self.issues), + "organization_slug": self.organization_slug, + } + + def render(self) -> str: + """Render the issues view.""" + header = f"[SENTRY ISSUES]: Organization: {self.organization_slug}" + + if self.project_slug: + header += f", Project: {self.project_slug}" + + if self.query: + header += f", Query: '{self.query}'" + + if self.status: + header += f", Status: {self.status}" + + header += f"\nFound {len(self.issues)} issues" + + if self.has_more: + header += " (more available)" + + if not self.issues: + return f"{header}\n\nNo issues found." + + issues_text = "" + for issue in self.issues: + issues_text += f"\n\n## {issue.get('shortId', 'Unknown')} - {issue.get('title', 'Untitled')}" + issues_text += f"\nStatus: {issue.get('status', 'Unknown')}" + issues_text += f"\nLevel: {issue.get('level', 'Unknown')}" + issues_text += f"\nEvents: {issue.get('count', 0)}" + issues_text += f"\nUsers affected: {issue.get('userCount', 0)}" + issues_text += f"\nFirst seen: {issue.get('firstSeen', 'Unknown')}" + issues_text += f"\nLast seen: {issue.get('lastSeen', 'Unknown')}" + issues_text += f"\nPermalink: {issue.get('permalink', 'Unknown')}" + + return f"{header}{issues_text}" + + +class SentryIssueDetailsObservation(Observation): + """Response from viewing a specific Sentry issue.""" + + organization_slug: str = Field(description="Organization slug") + issue_id: str = Field(description="Issue ID") + issue_data: Dict[str, Any] = Field(description="Issue data") + events: List[Dict[str, Any]] = Field(description="List of events for this issue") + has_more_events: bool = Field(description="Whether there are more events to fetch") + next_cursor: Optional[str] = Field(None, description="Cursor for fetching the next page of events") + + str_template: ClassVar[str] = "Issue {issue_id} with {event_count} events" + + def _get_details(self) -> Dict[str, Any]: + """Get details for string representation.""" + return { + "issue_id": self.issue_id, + "event_count": len(self.events), + } + + def render(self) -> str: + """Render the issue details view.""" + issue = self.issue_data + + header = f"[SENTRY ISSUE]: {issue.get('shortId', 'Unknown')} - {issue.get('title', 'Untitled')}" + header += f"\nOrganization: {self.organization_slug}" + + details = f"\nStatus: {issue.get('status', 'Unknown')}" + details += f"\nLevel: {issue.get('level', 'Unknown')}" + details += f"\nEvents: {issue.get('count', 0)}" + details += f"\nUsers affected: {issue.get('userCount', 0)}" + details += f"\nFirst seen: {issue.get('firstSeen', 'Unknown')}" + details += f"\nLast seen: {issue.get('lastSeen', 'Unknown')}" + details += f"\nPermalink: {issue.get('permalink', 'Unknown')}" + + events_header = f"\n\n## Events ({len(self.events)})" + if self.has_more_events: + events_header += " (more available)" + + events_text = "" + for event in self.events: + events_text += f"\n\n### Event {event.get('eventID', 'Unknown')}" + events_text += f"\nTimestamp: {event.get('dateCreated', 'Unknown')}" + + # Add user info if available + user = event.get('user') + if user: + events_text += f"\nUser: {user.get('username') or user.get('email') or user.get('ip_address') or 'Anonymous'}" + + # Add tags + tags = event.get('tags', []) + if tags: + events_text += "\nTags:" + for tag in tags: + events_text += f"\n - {tag.get('key', '')}: {tag.get('value', '')}" + + return f"{header}{details}{events_header}{events_text}" + + +class SentryEventDetailsObservation(Observation): + """Response from viewing a specific Sentry event.""" + + organization_slug: str = Field(description="Organization slug") + project_slug: str = Field(description="Project slug") + event_id: str = Field(description="Event ID") + event_data: Dict[str, Any] = Field(description="Event data") + + str_template: ClassVar[str] = "Event {event_id}" + + def render(self) -> str: + """Render the event details view.""" + event = self.event_data + + header = f"[SENTRY EVENT]: {event.get('eventID', 'Unknown')}" + header += f"\nOrganization: {self.organization_slug}" + header += f"\nProject: {self.project_slug}" + header += f"\nTitle: {event.get('title', 'Untitled')}" + + details = f"\nTimestamp: {event.get('dateCreated', 'Unknown')}" + + # Add user info if available + user = event.get('user') + if user: + details += f"\nUser: {user.get('username') or user.get('email') or user.get('ip_address') or 'Anonymous'}" + + # Add tags + tags = event.get('tags', []) + if tags: + details += "\nTags:" + for tag in tags: + details += f"\n - {tag.get('key', '')}: {tag.get('value', '')}" + + # Add exception info if available + exception_entries = [entry for entry in event.get('entries', []) if entry.get('type') == 'exception'] + if exception_entries: + details += "\n\n## Exception" + for entry in exception_entries: + for exception in entry.get('data', {}).get('values', []): + details += f"\n\n### {exception.get('type', 'Unknown')}: {exception.get('value', 'Unknown')}" + + # Add stack trace + frames = exception.get('stacktrace', {}).get('frames', []) + if frames: + details += "\n\nStack Trace:" + for frame in frames: + filename = frame.get('filename', frame.get('abs_path', 'Unknown')) + function = frame.get('function', 'Unknown') + lineno = frame.get('lineno', '?') + details += f"\n - {filename}:{lineno} in {function}" + + # Add context if available + context_line = frame.get('context_line') + if context_line: + details += f"\n {context_line.strip()}" + + return f"{header}{details}" + + +def view_sentry_issues( + codebase: Codebase, + organization_slug: str = DEFAULT_ORGANIZATION_SLUG, + project_slug: Optional[str] = None, + query: Optional[str] = None, + status: str = "unresolved", + limit: int = 20, + cursor: Optional[str] = None, +) -> SentryIssuesObservation: + """View Sentry issues for an organization or project. + + Args: + codebase: The codebase to operate on + organization_slug: Organization slug + project_slug: Optional project slug to filter by + query: Optional search query + status: Status filter (e.g., "resolved", "unresolved") + limit: Maximum number of issues to return + cursor: Pagination cursor + """ + try: + # Get auth token and installation UUID + auth_token = get_sentry_auth_token() + installation_uuid = get_installation_uuid(organization_slug) + + if not auth_token: + return SentryIssuesObservation( + status="error", + error="Sentry auth token not found. Please set the SENTRY_AUTH_TOKEN environment variable.", + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + status=status, + issues=[], + has_more=False, + ) + + if not installation_uuid: + available_orgs = get_available_organizations() + if not available_orgs: + org_message = "No Sentry organizations configured. Please set the SENTRY_CODEGEN_INSTALLATION_UUID or SENTRY_RAMP_INSTALLATION_UUID environment variables." + else: + org_message = f"Available organizations: {', '.join(available_orgs.keys())}" + + return SentryIssuesObservation( + status="error", + error=f"Sentry installation UUID not found for organization '{organization_slug}'. {org_message}", + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + status=status, + issues=[], + has_more=False, + ) + + # Initialize Sentry client + client = SentryClient(auth_token=auth_token, installation_uuid=installation_uuid) + + # Get issues + response = client.get_issues( + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + status=status, + limit=limit, + cursor=cursor, + ) + + # Extract pagination info + has_more = False + next_cursor = None + + if isinstance(response, dict): + issues = response.get('data', []) + + # Check for pagination info + pagination = response.get('pagination', {}) + has_more = pagination.get('hasMore', False) + next_cursor = pagination.get('nextCursor') + else: + issues = response + + return SentryIssuesObservation( + status="success", + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + status=status, + issues=issues, + has_more=has_more, + next_cursor=next_cursor, + ) + + except Exception as e: + return SentryIssuesObservation( + status="error", + error=f"Failed to view Sentry issues: {e!s}", + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + status=status, + issues=[], + has_more=False, + ) + + +def view_sentry_issue_details( + codebase: Codebase, + issue_id: str, + organization_slug: str = DEFAULT_ORGANIZATION_SLUG, + limit: int = 10, + cursor: Optional[str] = None, +) -> SentryIssueDetailsObservation: + """View details for a specific Sentry issue. + + Args: + codebase: The codebase to operate on + issue_id: Issue ID + organization_slug: Organization slug + limit: Maximum number of events to return + cursor: Pagination cursor + """ + try: + # Get auth token and installation UUID + auth_token = get_sentry_auth_token() + installation_uuid = get_installation_uuid(organization_slug) + + if not auth_token: + return SentryIssueDetailsObservation( + status="error", + error="Sentry auth token not found. Please set the SENTRY_AUTH_TOKEN environment variable.", + organization_slug=organization_slug, + issue_id=issue_id, + issue_data={}, + events=[], + has_more_events=False, + ) + + if not installation_uuid: + available_orgs = get_available_organizations() + if not available_orgs: + org_message = "No Sentry organizations configured. Please set the SENTRY_CODEGEN_INSTALLATION_UUID or SENTRY_RAMP_INSTALLATION_UUID environment variables." + else: + org_message = f"Available organizations: {', '.join(available_orgs.keys())}" + + return SentryIssueDetailsObservation( + status="error", + error=f"Sentry installation UUID not found for organization '{organization_slug}'. {org_message}", + organization_slug=organization_slug, + issue_id=issue_id, + issue_data={}, + events=[], + has_more_events=False, + ) + + # Initialize Sentry client + client = SentryClient(auth_token=auth_token, installation_uuid=installation_uuid) + + # Get issue details + issue = client.get_issue_details(issue_id=issue_id, organization_slug=organization_slug) + + # Get events for the issue + events_response = client.get_issue_events( + issue_id=issue_id, + organization_slug=organization_slug, + limit=limit, + cursor=cursor, + ) + + # Extract pagination info + has_more = False + next_cursor = None + + if isinstance(events_response, dict): + events = events_response.get('data', []) + + # Check for pagination info + pagination = events_response.get('pagination', {}) + has_more = pagination.get('hasMore', False) + next_cursor = pagination.get('nextCursor') + else: + events = events_response + + return SentryIssueDetailsObservation( + status="success", + organization_slug=organization_slug, + issue_id=issue_id, + issue_data=issue.dict() if hasattr(issue, 'dict') else issue, + events=events, + has_more_events=has_more, + next_cursor=next_cursor, + ) + + except Exception as e: + return SentryIssueDetailsObservation( + status="error", + error=f"Failed to view Sentry issue details: {e!s}", + organization_slug=organization_slug, + issue_id=issue_id, + issue_data={}, + events=[], + has_more_events=False, + ) + + +def view_sentry_event_details( + codebase: Codebase, + event_id: str, + organization_slug: str = DEFAULT_ORGANIZATION_SLUG, + project_slug: str = "codegen", +) -> SentryEventDetailsObservation: + """View details for a specific Sentry event. + + Args: + codebase: The codebase to operate on + event_id: Event ID + organization_slug: Organization slug + project_slug: Project slug + """ + try: + # Get auth token and installation UUID + auth_token = get_sentry_auth_token() + installation_uuid = get_installation_uuid(organization_slug) + + if not auth_token: + return SentryEventDetailsObservation( + status="error", + error="Sentry auth token not found. Please set the SENTRY_AUTH_TOKEN environment variable.", + organization_slug=organization_slug, + project_slug=project_slug, + event_id=event_id, + event_data={}, + ) + + if not installation_uuid: + available_orgs = get_available_organizations() + if not available_orgs: + org_message = "No Sentry organizations configured. Please set the SENTRY_CODEGEN_INSTALLATION_UUID or SENTRY_RAMP_INSTALLATION_UUID environment variables." + else: + org_message = f"Available organizations: {', '.join(available_orgs.keys())}" + + return SentryEventDetailsObservation( + status="error", + error=f"Sentry installation UUID not found for organization '{organization_slug}'. {org_message}", + organization_slug=organization_slug, + project_slug=project_slug, + event_id=event_id, + event_data={}, + ) + + # Initialize Sentry client + client = SentryClient(auth_token=auth_token, installation_uuid=installation_uuid) + + # Get event details + event = client.get_event_details( + event_id=event_id, + organization_slug=organization_slug, + project_slug=project_slug, + ) + + return SentryEventDetailsObservation( + status="success", + organization_slug=organization_slug, + project_slug=project_slug, + event_id=event_id, + event_data=event.dict() if hasattr(event, 'dict') else event, + ) + + except Exception as e: + return SentryEventDetailsObservation( + status="error", + error=f"Failed to view Sentry event details: {e!s}", + organization_slug=organization_slug, + project_slug=project_slug, + event_id=event_id, + event_data={}, + )