From c4d254107840e174a7cc62b23e6a4d24c334ed97 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:37:48 +0000 Subject: [PATCH 1/2] Add multimodal support for Linear --- src/codegen/extensions/events/linear.py | 74 +++++++- .../extensions/linear/linear_client.py | 177 +++++++++++++++++- src/codegen/extensions/linear/types.py | 39 +++- 3 files changed, 285 insertions(+), 5 deletions(-) diff --git a/src/codegen/extensions/events/linear.py b/src/codegen/extensions/events/linear.py index 4fe5b2e91..259f8a56c 100644 --- a/src/codegen/extensions/events/linear.py +++ b/src/codegen/extensions/events/linear.py @@ -1,10 +1,13 @@ import logging -from typing import Any, Callable, TypeVar +import os +import tempfile +from typing import Any, Callable, TypeVar, List, Optional from pydantic import BaseModel from codegen.extensions.events.interface import EventHandlerManagerProtocol -from codegen.extensions.linear.types import LinearEvent +from codegen.extensions.linear.linear_client import LinearClient +from codegen.extensions.linear.types import LinearEvent, LinearAttachment from codegen.shared.logging.get_logger import get_logger logger = get_logger(__name__) @@ -18,6 +21,17 @@ class Linear(EventHandlerManagerProtocol): def __init__(self, app): self.app = app self.registered_handlers = {} + self._client = None + + @property + def client(self) -> LinearClient: + """Get the Linear client instance.""" + if not self._client: + access_token = os.environ.get("LINEAR_ACCESS_TOKEN") + if not access_token: + raise ValueError("LINEAR_ACCESS_TOKEN environment variable is not set") + self._client = LinearClient(access_token=access_token) + return self._client def unsubscribe_all_handlers(self): logger.info("[HANDLERS] Clearing all handlers") @@ -44,6 +58,18 @@ def new_func(raw_event: dict): # Parse event into LinearEvent type event = LinearEvent.model_validate(raw_event) + + # Check if this is an issue event and has attachments + if event_type == "Issue" and hasattr(event.data, "id"): + try: + # Get attachments for the issue + attachments = self.client.get_issue_attachments(event.data.id) + if attachments: + event.attachments = attachments + logger.info(f"[HANDLER] Found {len(attachments)} attachments for issue {event.data.id}") + except Exception as e: + logger.error(f"[HANDLER] Error getting attachments: {e}") + return func(event) self.registered_handlers[event_name] = new_func @@ -83,3 +109,47 @@ async def handle(self, event: dict) -> dict: except Exception as e: logger.exception(f"Error handling Linear event: {e}") return {"error": f"Failed to handle event: {e!s}"} + + def download_attachment(self, attachment: LinearAttachment, directory: Optional[str] = None) -> str: + """Download a file attachment from Linear. + + Args: + attachment: The LinearAttachment object + directory: Optional directory to save the file to. If not provided, uses a temporary directory. + + Returns: + Path to the downloaded file + """ + try: + # Download the attachment + content = self.client.download_attachment(attachment.url) + + # Determine file path + if directory: + os.makedirs(directory, exist_ok=True) + file_path = os.path.join(directory, attachment.title) + else: + # Create a temporary file + temp_dir = tempfile.mkdtemp() + file_path = os.path.join(temp_dir, attachment.title) + + # Write the file + with open(file_path, "wb") as f: + f.write(content) + + logger.info(f"[HANDLER] Downloaded attachment to {file_path}") + return file_path + except Exception as e: + logger.error(f"[HANDLER] Error downloading attachment: {e}") + raise Exception(f"Failed to download attachment: {e}") + + def upload_file(self, file_path: str) -> str: + """Upload a file to Linear. + + Args: + file_path: Path to the file to upload + + Returns: + URL of the uploaded file + """ + return self.client.upload_file(file_path) diff --git a/src/codegen/extensions/linear/linear_client.py b/src/codegen/extensions/linear/linear_client.py index 0c3803153..05c77d08d 100644 --- a/src/codegen/extensions/linear/linear_client.py +++ b/src/codegen/extensions/linear/linear_client.py @@ -1,11 +1,20 @@ import os -from typing import Optional +import mimetypes +from typing import Optional, List, Dict, Any, BinaryIO +from pathlib import Path import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from codegen.extensions.linear.types import LinearComment, LinearIssue, LinearTeam, LinearUser +from codegen.extensions.linear.types import ( + LinearComment, + LinearIssue, + LinearTeam, + LinearUser, + LinearAttachment, + LinearUploadResponse +) from codegen.shared.logging.get_logger import get_logger logger = get_logger(__name__) @@ -293,3 +302,167 @@ def get_teams(self) -> list[LinearTeam]: except Exception as e: msg = f"Error getting teams\n{data}\n{e}" raise Exception(msg) + + def get_issue_attachments(self, issue_id: str) -> List[LinearAttachment]: + """Get all attachments for an issue. + + Args: + issue_id: ID of the issue to get attachments for + + Returns: + List of LinearAttachment objects + """ + query = """ + query getIssueAttachments($issueId: String!) { + issue(id: $issueId) { + attachments { + nodes { + id + url + title + subtitle + size + contentType + source + } + } + } + } + """ + + variables = {"issueId": issue_id} + response = self.session.post( + self.api_endpoint, + headers=self.api_headers, + json={"query": query, "variables": variables}, + ) + data = response.json() + + try: + attachments_data = data["data"]["issue"]["attachments"]["nodes"] + return [ + LinearAttachment( + id=attachment["id"], + url=attachment["url"], + title=attachment["title"], + subtitle=attachment.get("subtitle"), + size=attachment.get("size"), + content_type=attachment.get("contentType"), + source=attachment.get("source"), + issue_id=issue_id, + ) + for attachment in attachments_data + ] + except Exception as e: + logger.error(f"Error getting issue attachments: {e}") + return [] + + def download_attachment(self, attachment_url: str) -> bytes: + """Download a file attachment from Linear. + + Args: + attachment_url: URL of the attachment to download + + Returns: + Binary content of the attachment + """ + # Linear files are stored at uploads.linear.app + headers = {"Authorization": f"Bearer {self.access_token}"} + + try: + response = self.session.get(attachment_url, headers=headers) + response.raise_for_status() + return response.content + except Exception as e: + logger.error(f"Error downloading attachment: {e}") + raise Exception(f"Failed to download attachment: {e}") + + def request_upload_url(self, content_type: str, filename: str, size: int) -> LinearUploadResponse: + """Request a pre-signed URL for file upload. + + Args: + content_type: MIME type of the file + filename: Name of the file + size: Size of the file in bytes + + Returns: + LinearUploadResponse with upload URL and headers + """ + mutation = """ + mutation FileUpload($contentType: String!, $filename: String!, $size: Int!) { + fileUpload(contentType: $contentType, filename: $filename, size: $size) { + success + uploadFile { + assetUrl + uploadUrl + headers { + key + value + } + } + } + } + """ + + variables = { + "contentType": content_type, + "filename": filename, + "size": size, + } + + response = self.session.post( + self.api_endpoint, + headers=self.api_headers, + json={"query": mutation, "variables": variables}, + ) + data = response.json() + + try: + return LinearUploadResponse.model_validate(data["data"]) + except Exception as e: + logger.error(f"Error requesting upload URL: {e}") + raise Exception(f"Failed to request upload URL: {e}") + + def upload_file(self, file_path: str) -> str: + """Upload a file to Linear. + + Args: + file_path: Path to the file to upload + + Returns: + URL of the uploaded file + """ + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + # Get file info + file_size = path.stat().st_size + file_name = path.name + content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + + # Request upload URL + upload_response = self.request_upload_url(content_type, file_name, file_size) + + if not upload_response.success or not upload_response.uploadFile: + raise Exception("Failed to request upload URL") + + upload_url = upload_response.uploadFile.uploadUrl + asset_url = upload_response.uploadFile.assetUrl + + # Prepare headers for the PUT request + headers = { + "Content-Type": content_type, + "Cache-Control": "public, max-age=31536000", + } + + # Add headers from the upload response + for header in upload_response.uploadFile.headers: + headers[header.key] = header.value + + # Upload the file + with open(file_path, "rb") as file: + response = self.session.put(upload_url, headers=headers, data=file) + response.raise_for_status() + + return asset_url diff --git a/src/codegen/extensions/linear/types.py b/src/codegen/extensions/linear/types.py index fb9439399..8203a4ea6 100644 --- a/src/codegen/extensions/linear/types.py +++ b/src/codegen/extensions/linear/types.py @@ -1,4 +1,5 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field +from typing import List, Optional class LinearUser(BaseModel): @@ -28,6 +29,41 @@ class LinearIssue(BaseModel): team_id: str | None = None +class LinearAttachment(BaseModel): + """Represents a file attachment in Linear.""" + + id: str + url: str + title: str + subtitle: Optional[str] = None + size: Optional[int] = None + content_type: Optional[str] = None + source: Optional[str] = None + issue_id: Optional[str] = None + + +class LinearUploadHeader(BaseModel): + """Header for file upload.""" + + key: str + value: str + + +class LinearUploadFile(BaseModel): + """Response from file upload request.""" + + assetUrl: str + uploadUrl: str + headers: List[LinearUploadHeader] + + +class LinearUploadResponse(BaseModel): + """Response from fileUpload mutation.""" + + success: bool + uploadFile: LinearUploadFile + + class LinearEvent(BaseModel): """Represents a Linear webhook event.""" @@ -38,3 +74,4 @@ class LinearEvent(BaseModel): created_at: str | None = None # ISO timestamp organization_id: str | None = None team_id: str | None = None + attachments: List[LinearAttachment] = Field(default_factory=list) # Attachments associated with the event From 7fd3312fc79349c8ce8671100d2f1a4ddfd53be4 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:38:34 +0000 Subject: [PATCH 2/2] Automated pre-commit update --- src/codegen/extensions/events/linear.py | 36 ++++---- .../extensions/linear/linear_client.py | 85 +++++++++---------- src/codegen/extensions/linear/types.py | 15 ++-- 3 files changed, 68 insertions(+), 68 deletions(-) diff --git a/src/codegen/extensions/events/linear.py b/src/codegen/extensions/events/linear.py index 259f8a56c..cd676757f 100644 --- a/src/codegen/extensions/events/linear.py +++ b/src/codegen/extensions/events/linear.py @@ -1,13 +1,13 @@ import logging import os import tempfile -from typing import Any, Callable, TypeVar, List, Optional +from typing import Any, Callable, Optional, TypeVar from pydantic import BaseModel from codegen.extensions.events.interface import EventHandlerManagerProtocol from codegen.extensions.linear.linear_client import LinearClient -from codegen.extensions.linear.types import LinearEvent, LinearAttachment +from codegen.extensions.linear.types import LinearAttachment, LinearEvent from codegen.shared.logging.get_logger import get_logger logger = get_logger(__name__) @@ -29,7 +29,8 @@ def client(self) -> LinearClient: if not self._client: access_token = os.environ.get("LINEAR_ACCESS_TOKEN") if not access_token: - raise ValueError("LINEAR_ACCESS_TOKEN environment variable is not set") + msg = "LINEAR_ACCESS_TOKEN environment variable is not set" + raise ValueError(msg) self._client = LinearClient(access_token=access_token) return self._client @@ -58,7 +59,7 @@ def new_func(raw_event: dict): # Parse event into LinearEvent type event = LinearEvent.model_validate(raw_event) - + # Check if this is an issue event and has attachments if event_type == "Issue" and hasattr(event.data, "id"): try: @@ -68,8 +69,8 @@ def new_func(raw_event: dict): event.attachments = attachments logger.info(f"[HANDLER] Found {len(attachments)} attachments for issue {event.data.id}") except Exception as e: - logger.error(f"[HANDLER] Error getting attachments: {e}") - + logger.exception(f"[HANDLER] Error getting attachments: {e}") + return func(event) self.registered_handlers[event_name] = new_func @@ -109,21 +110,21 @@ async def handle(self, event: dict) -> dict: except Exception as e: logger.exception(f"Error handling Linear event: {e}") return {"error": f"Failed to handle event: {e!s}"} - + def download_attachment(self, attachment: LinearAttachment, directory: Optional[str] = None) -> str: """Download a file attachment from Linear. - + Args: attachment: The LinearAttachment object directory: Optional directory to save the file to. If not provided, uses a temporary directory. - + Returns: Path to the downloaded file """ try: # Download the attachment content = self.client.download_attachment(attachment.url) - + # Determine file path if directory: os.makedirs(directory, exist_ok=True) @@ -132,23 +133,24 @@ def download_attachment(self, attachment: LinearAttachment, directory: Optional[ # Create a temporary file temp_dir = tempfile.mkdtemp() file_path = os.path.join(temp_dir, attachment.title) - + # Write the file with open(file_path, "wb") as f: f.write(content) - + logger.info(f"[HANDLER] Downloaded attachment to {file_path}") return file_path except Exception as e: - logger.error(f"[HANDLER] Error downloading attachment: {e}") - raise Exception(f"Failed to download attachment: {e}") - + logger.exception(f"[HANDLER] Error downloading attachment: {e}") + msg = f"Failed to download attachment: {e}" + raise Exception(msg) + def upload_file(self, file_path: str) -> str: """Upload a file to Linear. - + Args: file_path: Path to the file to upload - + Returns: URL of the uploaded file """ diff --git a/src/codegen/extensions/linear/linear_client.py b/src/codegen/extensions/linear/linear_client.py index 05c77d08d..2febfca0b 100644 --- a/src/codegen/extensions/linear/linear_client.py +++ b/src/codegen/extensions/linear/linear_client.py @@ -1,20 +1,13 @@ -import os import mimetypes -from typing import Optional, List, Dict, Any, BinaryIO +import os from pathlib import Path +from typing import Optional import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from codegen.extensions.linear.types import ( - LinearComment, - LinearIssue, - LinearTeam, - LinearUser, - LinearAttachment, - LinearUploadResponse -) +from codegen.extensions.linear.types import LinearAttachment, LinearComment, LinearIssue, LinearTeam, LinearUploadResponse, LinearUser from codegen.shared.logging.get_logger import get_logger logger = get_logger(__name__) @@ -302,13 +295,13 @@ def get_teams(self) -> list[LinearTeam]: except Exception as e: msg = f"Error getting teams\n{data}\n{e}" raise Exception(msg) - - def get_issue_attachments(self, issue_id: str) -> List[LinearAttachment]: + + def get_issue_attachments(self, issue_id: str) -> list[LinearAttachment]: """Get all attachments for an issue. - + Args: issue_id: ID of the issue to get attachments for - + Returns: List of LinearAttachment objects """ @@ -329,7 +322,7 @@ def get_issue_attachments(self, issue_id: str) -> List[LinearAttachment]: } } """ - + variables = {"issueId": issue_id} response = self.session.post( self.api_endpoint, @@ -337,7 +330,7 @@ def get_issue_attachments(self, issue_id: str) -> List[LinearAttachment]: json={"query": query, "variables": variables}, ) data = response.json() - + try: attachments_data = data["data"]["issue"]["attachments"]["nodes"] return [ @@ -354,37 +347,38 @@ def get_issue_attachments(self, issue_id: str) -> List[LinearAttachment]: for attachment in attachments_data ] except Exception as e: - logger.error(f"Error getting issue attachments: {e}") + logger.exception(f"Error getting issue attachments: {e}") return [] - + def download_attachment(self, attachment_url: str) -> bytes: """Download a file attachment from Linear. - + Args: attachment_url: URL of the attachment to download - + Returns: Binary content of the attachment """ # Linear files are stored at uploads.linear.app headers = {"Authorization": f"Bearer {self.access_token}"} - + try: response = self.session.get(attachment_url, headers=headers) response.raise_for_status() return response.content except Exception as e: - logger.error(f"Error downloading attachment: {e}") - raise Exception(f"Failed to download attachment: {e}") - + logger.exception(f"Error downloading attachment: {e}") + msg = f"Failed to download attachment: {e}" + raise Exception(msg) + def request_upload_url(self, content_type: str, filename: str, size: int) -> LinearUploadResponse: """Request a pre-signed URL for file upload. - + Args: content_type: MIME type of the file filename: Name of the file size: Size of the file in bytes - + Returns: LinearUploadResponse with upload URL and headers """ @@ -403,66 +397,69 @@ def request_upload_url(self, content_type: str, filename: str, size: int) -> Lin } } """ - + variables = { "contentType": content_type, "filename": filename, "size": size, } - + response = self.session.post( self.api_endpoint, headers=self.api_headers, json={"query": mutation, "variables": variables}, ) data = response.json() - + try: return LinearUploadResponse.model_validate(data["data"]) except Exception as e: - logger.error(f"Error requesting upload URL: {e}") - raise Exception(f"Failed to request upload URL: {e}") - + logger.exception(f"Error requesting upload URL: {e}") + msg = f"Failed to request upload URL: {e}" + raise Exception(msg) + def upload_file(self, file_path: str) -> str: """Upload a file to Linear. - + Args: file_path: Path to the file to upload - + Returns: URL of the uploaded file """ path = Path(file_path) if not path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - + msg = f"File not found: {file_path}" + raise FileNotFoundError(msg) + # Get file info file_size = path.stat().st_size file_name = path.name content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" - + # Request upload URL upload_response = self.request_upload_url(content_type, file_name, file_size) - + if not upload_response.success or not upload_response.uploadFile: - raise Exception("Failed to request upload URL") - + msg = "Failed to request upload URL" + raise Exception(msg) + upload_url = upload_response.uploadFile.uploadUrl asset_url = upload_response.uploadFile.assetUrl - + # Prepare headers for the PUT request headers = { "Content-Type": content_type, "Cache-Control": "public, max-age=31536000", } - + # Add headers from the upload response for header in upload_response.uploadFile.headers: headers[header.key] = header.value - + # Upload the file with open(file_path, "rb") as file: response = self.session.put(upload_url, headers=headers, data=file) response.raise_for_status() - + return asset_url diff --git a/src/codegen/extensions/linear/types.py b/src/codegen/extensions/linear/types.py index 8203a4ea6..799373a83 100644 --- a/src/codegen/extensions/linear/types.py +++ b/src/codegen/extensions/linear/types.py @@ -1,5 +1,6 @@ +from typing import Optional + from pydantic import BaseModel, Field -from typing import List, Optional class LinearUser(BaseModel): @@ -31,7 +32,7 @@ class LinearIssue(BaseModel): class LinearAttachment(BaseModel): """Represents a file attachment in Linear.""" - + id: str url: str title: str @@ -44,22 +45,22 @@ class LinearAttachment(BaseModel): class LinearUploadHeader(BaseModel): """Header for file upload.""" - + key: str value: str class LinearUploadFile(BaseModel): """Response from file upload request.""" - + assetUrl: str uploadUrl: str - headers: List[LinearUploadHeader] + headers: list[LinearUploadHeader] class LinearUploadResponse(BaseModel): """Response from fileUpload mutation.""" - + success: bool uploadFile: LinearUploadFile @@ -74,4 +75,4 @@ class LinearEvent(BaseModel): created_at: str | None = None # ISO timestamp organization_id: str | None = None team_id: str | None = None - attachments: List[LinearAttachment] = Field(default_factory=list) # Attachments associated with the event + attachments: list[LinearAttachment] = Field(default_factory=list) # Attachments associated with the event