From 565f8020641359e8f8aa44653c0cf5d1c94ab392 Mon Sep 17 00:00:00 2001 From: jonah-legg Date: Mon, 5 Jan 2026 14:15:16 -0500 Subject: [PATCH 1/5] Added file_upload and create_comment functions Added functionality to upload files (returning the asset url) and creating comments (returning the comment object) --- linear_api/managers/issue_manager.py | 270 +++++++++++++++++++++------ 1 file changed, 212 insertions(+), 58 deletions(-) diff --git a/linear_api/managers/issue_manager.py b/linear_api/managers/issue_manager.py index e034fd4..1faa1d1 100644 --- a/linear_api/managers/issue_manager.py +++ b/linear_api/managers/issue_manager.py @@ -6,21 +6,23 @@ import json from datetime import datetime -from typing import Dict, List, Any +from typing import Any, Dict, List from urllib.parse import urlparse -from .base_manager import BaseManager -from ..domain import IssueRelation, CustomerNeedResponse from ..domain import ( + Comment, + CustomerNeedResponse, + IssueRelation, + LinearAttachmentInput, LinearIssue, LinearIssueInput, LinearIssueUpdateInput, - LinearAttachmentInput, LinearPriority, - Comment, - LinearUser, Reaction + LinearUser, + Reaction, ) -from ..utils import process_issue_data, enrich_with_client +from ..utils import enrich_with_client, process_issue_data +from .base_manager import BaseManager class IssueManager(BaseManager[LinearIssue]): @@ -181,7 +183,11 @@ def create(self, issue: LinearIssueInput) -> LinearIssue: # Create the issue response = self._execute_query(mutation, {"input": input_vars}) - if not response or "issueCreate" not in response or not response["issueCreate"]["issue"]: + if ( + not response + or "issueCreate" not in response + or not response["issueCreate"]["issue"] + ): raise ValueError(f"Failed to create issue '{issue.title}'") new_issue_id = response["issueCreate"]["issue"]["id"] @@ -241,7 +247,9 @@ def update(self, issue_id: str, update_data: LinearIssueUpdateInput) -> LinearIs try: current_issue = self.get(issue_id) - parent_id = current_issue.parentId if hasattr(current_issue, 'parentId') else None + parent_id = ( + current_issue.parentId if hasattr(current_issue, "parentId") else None + ) old_state_id = current_issue.state.id if current_issue.state else None except ValueError: parent_id = None @@ -253,7 +261,11 @@ def update(self, issue_id: str, update_data: LinearIssueUpdateInput) -> LinearIs # Update the issue response = self._execute_query(mutation, {"id": issue_id, "input": input_vars}) - if not response or "issueUpdate" not in response or not response["issueUpdate"]["success"]: + if ( + not response + or "issueUpdate" not in response + or not response["issueUpdate"]["success"] + ): raise ValueError(f"Failed to update issue with ID: {issue_id}") self._cache_invalidate("issues_by_id", issue_id) @@ -287,7 +299,11 @@ def update(self, issue_id: str, update_data: LinearIssueUpdateInput) -> LinearIs updated_issue = self.get(issue_id) - if old_state_id and updated_issue.state and old_state_id != updated_issue.state.id: + if ( + old_state_id + and updated_issue.state + and old_state_id != updated_issue.state.id + ): if updated_issue.team and updated_issue.team.id: self._cache_clear("states_by_team_id") self._cache_clear("states_by_team_id_True") @@ -378,9 +394,7 @@ def get_by_team(self, team_name: str) -> Dict[str, LinearIssue]: # Get all issue IDs for this team using our improved pagination method issue_objects = self._handle_pagination( - query, - {"teamId": team_id}, - ["issues", "nodes"] + query, {"teamId": team_id}, ["issues", "nodes"] ) # Convert to dictionary of ID -> LinearIssue @@ -432,9 +446,7 @@ def get_by_project(self, project_id: str) -> Dict[str, LinearIssue]: # Get all issue IDs for this project using our improved pagination method issue_objects = self._handle_pagination( - query, - {"projectId": project_id}, - ["project", "issues", "nodes"] + query, {"projectId": project_id}, ["project", "issues", "nodes"] ) # Convert to dictionary of ID -> LinearIssue @@ -480,11 +492,7 @@ def get_all(self) -> Dict[str, LinearIssue]: """ # Get all issue IDs using our improved pagination method - issue_objects = self._handle_pagination( - query, - {}, - ["issues", "nodes"] - ) + issue_objects = self._handle_pagination(query, {}, ["issues", "nodes"]) # Convert to dictionary of ID -> LinearIssue issues = {} @@ -492,9 +500,10 @@ def get_all(self) -> Dict[str, LinearIssue]: all_issue_ids = [issue["id"] for issue in issue_objects] for i in range(0, len(all_issue_ids), batch_size): - batch = all_issue_ids[i:i + batch_size] + batch = all_issue_ids[i : i + batch_size] print( - f"Processing batch {i // batch_size + 1}/{(len(all_issue_ids) - 1) // batch_size + 1} ({len(batch)} issues)") + f"Processing batch {i // batch_size + 1}/{(len(all_issue_ids) - 1) // batch_size + 1} ({len(batch)} issues)" + ) for issue_id in batch: try: @@ -525,7 +534,7 @@ def is_valid_url(url): return False try: result = urlparse(url) - return all([result.scheme in ['http', 'https'], result.netloc]) + return all([result.scheme in ["http", "https"], result.netloc]) except: return False @@ -619,10 +628,7 @@ def get_attachments(self, issue_id: str) -> List[Dict[str, Any]]: return [] attachments = self._extract_and_cache( - response, - ["issue", "attachments"], - "attachments_by_issue", - issue_id + response, ["issue", "attachments"], "attachments_by_issue", issue_id ) return attachments @@ -680,10 +686,7 @@ def get_history(self, issue_id: str) -> List[Dict[str, Any]]: return [] history = self._extract_and_cache( - response, - ["issue", "history"], - "history_by_issue", - issue_id + response, ["issue", "history"], "history_by_issue", issue_id ) return history @@ -738,7 +741,7 @@ def get_comments(self, issue_id: str) -> List[Comment]: query, {"issueId": issue_id}, ["issue", "comments", "nodes"], - Comment # Pass the model class for automatic conversion + Comment, # Pass the model class for automatic conversion ) # Cache the result @@ -746,6 +749,117 @@ def get_comments(self, issue_id: str) -> List[Comment]: return comments + def create_comment( + self, issue_id: str, body: str, parent_id: str = None + ) -> Comment: + """ + Create a comment for an issue (or reply to an existing comment). + + Args: + issue_id: The ID of the issue to comment on + body: The text body of the comment + parent_id: Optional ID of the parent comment to reply to + + Returns: + The created Comment object + """ + mutation = """ + mutation CreateComment($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + body + createdAt + updatedAt + } + } + } + """ + + input_vars = {"input": {"body": body, "issueId": issue_id}} + if parent_id is not None: + input_vars["input"]["parentId"] = parent_id + + response = self._execute_query(mutation, input_vars) + + if ( + not response + or "commentCreate" not in response + or not response["commentCreate"].get("comment") + ): + raise ValueError(f"Failed to create comment for issue {issue_id}") + + comment_data = response["commentCreate"]["comment"] + + # Invalidate cache for issue comments + self._cache_invalidate("comments_by_issue", issue_id) + + return Comment(**comment_data) + + async def file_upload(self, file_data: bytes, content_type: str, filename: str) -> str: + """ + Upload a file to Linear. + + Args: + file_data: The binary content of the file + content_type: The MIME type of the file (e.g., 'image/jpeg') + filename: The name of the file + + Returns: + str: The final asset URL for the uploaded file + """ + mutation = """ + mutation fileUpload($contentType: String!, $filename: String!, $size: Int!) { + fileUpload(contentType: $contentType, filename: $filename, size: $size) { + success + uploadFile { + uploadUrl + assetUrl + headers { + key + value + } + } + } + } + """ + + variables = { + "contentType": content_type, + "filename": filename, + "size": len(file_data) + } + + response = self._execute_query(mutation, variables) + + if ( + not response + or "fileUpload" not in response + or not response["fileUpload"].get("uploadFile") + ): + raise ValueError("Failed to get file upload URL") + + upload_info = response["fileUpload"]["uploadFile"] + + headers = { + 'Content-Type': content_type, + 'Cache-Control': 'public, max-age=31536000' + } + for header in upload_info['headers']: + headers[header['key']] = header['value'] + + async with aiohttp.ClientSession() as session: + async with session.put( + upload_info['uploadUrl'], + headers=headers, + data=file_data + ) as response: + if response.status not in (200, 201): + raise Exception(f"Upload failed with status {response.status}") + + return upload_info['assetUrl'] + def get_children(self, issue_id: str) -> Dict[str, LinearIssue]: """ Get child issues for an issue. @@ -777,9 +891,7 @@ def get_children(self, issue_id: str) -> Dict[str, LinearIssue]: # Use our improved pagination method issue_nodes = self._handle_pagination( - query, - {"parentId": issue_id}, - ["issues", "nodes"] + query, {"parentId": issue_id}, ["issues", "nodes"] ) # Convert to dictionary of ID -> LinearIssue @@ -830,10 +942,14 @@ def get_reactions(self, issue_id: str) -> List[Reaction]: """ response = self._execute_query(query, {"issueId": issue_id}) - if not response or "issue" not in response or not response["issue"] or "reactions" not in response["issue"]: + if ( + not response + or "issue" not in response + or not response["issue"] + or "reactions" not in response["issue"] + ): return [] - reactions = [] for reaction_data in response["issue"]["reactions"]: try: @@ -893,7 +1009,7 @@ def get_subscribers(self, issue_id: str) -> List[LinearUser]: query, {"issueId": issue_id}, ["issue", "subscribers", "nodes"], - LinearUser # Pass the model class for automatic conversion + LinearUser, # Pass the model class for automatic conversion ) # Cache the result @@ -939,8 +1055,13 @@ def get_relations(self, issue_id: str) -> List[IssueRelation]: """ response = self._execute_query(query, {"issueId": issue_id}) - if not response or "issue" not in response or not response["issue"] or "relations" not in response[ - "issue"] or "nodes" not in response["issue"]["relations"]: + if ( + not response + or "issue" not in response + or not response["issue"] + or "relations" not in response["issue"] + or "nodes" not in response["issue"]["relations"] + ): return [] relations = [] @@ -970,7 +1091,7 @@ def get_inverse_relations(self, issue_id: str) -> List[IssueRelation]: if cached_relations: return cached_relations - # Запрос для inverseRelations, который является Connection + # Query for inverseRelations, which is a Connection query = """ query($issueId: String!) { issue(id: $issueId) { @@ -995,8 +1116,13 @@ def get_inverse_relations(self, issue_id: str) -> List[IssueRelation]: response = self._execute_query(query, {"issueId": issue_id}) - if not response or "issue" not in response or not response["issue"] or "inverseRelations" not in response[ - "issue"] or "nodes" not in response["issue"]["inverseRelations"]: + if ( + not response + or "issue" not in response + or not response["issue"] + or "inverseRelations" not in response["issue"] + or "nodes" not in response["issue"]["inverseRelations"] + ): return [] relations = [] @@ -1052,8 +1178,13 @@ def get_needs(self, issue_id: str) -> List[CustomerNeedResponse]: response = self._execute_query(query, {"issueId": issue_id}) - if not response or "issue" not in response or not response["issue"] or "needs" not in response[ - "issue"] or "nodes" not in response["issue"]["needs"]: + if ( + not response + or "issue" not in response + or not response["issue"] + or "needs" not in response["issue"] + or "nodes" not in response["issue"]["needs"] + ): return [] needs = [] @@ -1098,8 +1229,7 @@ def _set_parent_issue(self, child_id: str, parent_id: str) -> Dict[str, Any]: """ response = self._execute_query( - mutation, - {"id": child_id, "input": {"parentId": parent_id}} + mutation, {"id": child_id, "input": {"parentId": parent_id}} ) # Invalidate caches for both parent and child issues @@ -1108,7 +1238,9 @@ def _set_parent_issue(self, child_id: str, parent_id: str) -> Dict[str, Any]: return response - def _build_issue_input_vars(self, issue: LinearIssueInput, team_id: str) -> Dict[str, Any]: + def _build_issue_input_vars( + self, issue: LinearIssueInput, team_id: str + ) -> Dict[str, Any]: """ Build input variables for creating an issue. @@ -1158,8 +1290,13 @@ def _build_issue_input_vars(self, issue: LinearIssueInput, team_id: str) -> Dict # Handle additional fields optional_fields = [ - "descriptionData", "subscriberIds", "sortOrder", "prioritySortOrder", - "subIssueSortOrder", "displayIconUrl", "preserveSortOrderOnCreate" + "descriptionData", + "subscriberIds", + "sortOrder", + "prioritySortOrder", + "subIssueSortOrder", + "displayIconUrl", + "preserveSortOrderOnCreate", ] for field in optional_fields: @@ -1180,7 +1317,9 @@ def _build_issue_input_vars(self, issue: LinearIssueInput, team_id: str) -> Dict return input_vars - def _build_issue_update_vars(self, issue_id: str, update_data: LinearIssueUpdateInput) -> Dict[str, Any]: + def _build_issue_update_vars( + self, issue_id: str, update_data: LinearIssueUpdateInput + ) -> Dict[str, Any]: """ Build input variables for updating an issue. @@ -1192,7 +1331,9 @@ def _build_issue_update_vars(self, issue_id: str, update_data: LinearIssueUpdate Input variables for the GraphQL mutation """ # Convert the Pydantic model to a dictionary, excluding None values - update_dict = {k: v for k, v in update_data.model_dump().items() if v is not None} + update_dict = { + k: v for k, v in update_data.model_dump().items() if v is not None + } # Build the input variables input_vars = {} @@ -1209,20 +1350,33 @@ def _build_issue_update_vars(self, issue_id: str, update_data: LinearIssueUpdate # Handle stateName conversion if "stateName" in update_dict and team_id: - state_id = self.client.teams.get_state_id_by_name(update_dict.pop("stateName"), team_id) + state_id = self.client.teams.get_state_id_by_name( + update_dict.pop("stateName"), team_id + ) input_vars["stateId"] = state_id # Handle projectName conversion if "projectName" in update_dict and team_id: - project_id = self.client.projects.get_id_by_name(update_dict.pop("projectName"), team_id) + project_id = self.client.projects.get_id_by_name( + update_dict.pop("projectName"), team_id + ) input_vars["projectId"] = project_id # Handle priority as an enum value - if "priority" in update_dict and isinstance(update_dict["priority"], LinearPriority): + if "priority" in update_dict and isinstance( + update_dict["priority"], LinearPriority + ): input_vars["priority"] = update_dict.pop("priority").value # Handle datetime fields - datetime_fields = ["dueDate", "createdAt", "slaBreachesAt", "slaStartedAt", "snoozedUntilAt", "completedAt"] + datetime_fields = [ + "dueDate", + "createdAt", + "slaBreachesAt", + "slaStartedAt", + "snoozedUntilAt", + "completedAt", + ] for field in datetime_fields: if field in update_dict and isinstance(update_dict[field], datetime): input_vars[field] = update_dict.pop(field).isoformat() From 60470c923da14557daff2677631664a23c005947 Mon Sep 17 00:00:00 2001 From: jonah-legg Date: Mon, 5 Jan 2026 14:23:39 -0500 Subject: [PATCH 2/5] Add aiohttp import to issue_manager.py --- linear_api/managers/issue_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/linear_api/managers/issue_manager.py b/linear_api/managers/issue_manager.py index 1faa1d1..6f8923b 100644 --- a/linear_api/managers/issue_manager.py +++ b/linear_api/managers/issue_manager.py @@ -5,6 +5,7 @@ """ import json +import aiohttp from datetime import datetime from typing import Any, Dict, List from urllib.parse import urlparse From 682833cbd3077353fd1cb5928f4b559cf249dbfe Mon Sep 17 00:00:00 2001 From: jonah-legg Date: Mon, 5 Jan 2026 14:31:45 -0500 Subject: [PATCH 3/5] Add aiohttp as a dependency in setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index d1f137b..091acd6 100644 --- a/setup.py +++ b/setup.py @@ -25,5 +25,6 @@ install_requires=[ "pydantic>=2.0.0", "requests>=2.25.0", + "aiohttp>=3.13.3" ], ) From dc38ac0de7cb4524b91b435fbb53517738fc1524 Mon Sep 17 00:00:00 2001 From: jonah-legg Date: Mon, 5 Jan 2026 14:34:33 -0500 Subject: [PATCH 4/5] Update linear_api/managers/issue_manager.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- linear_api/managers/issue_manager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/linear_api/managers/issue_manager.py b/linear_api/managers/issue_manager.py index 6f8923b..760d0b0 100644 --- a/linear_api/managers/issue_manager.py +++ b/linear_api/managers/issue_manager.py @@ -854,11 +854,11 @@ async def file_upload(self, file_data: bytes, content_type: str, filename: str) async with session.put( upload_info['uploadUrl'], headers=headers, - data=file_data - ) as response: - if response.status not in (200, 201): - raise Exception(f"Upload failed with status {response.status}") - + data=file_data, + timeout=aiohttp.ClientTimeout(total=300) + ) as upload_response: + if upload_response.status not in (200, 201): + raise ValueError(f"Upload failed with status {upload_response.status}") return upload_info['assetUrl'] def get_children(self, issue_id: str) -> Dict[str, LinearIssue]: From 2279e73b2e88fccbdcba5076dc2e1a4157a810cd Mon Sep 17 00:00:00 2001 From: jonah-legg Date: Mon, 5 Jan 2026 15:14:14 -0500 Subject: [PATCH 5/5] Add username parameter to create_comment method --- linear_api/managers/issue_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linear_api/managers/issue_manager.py b/linear_api/managers/issue_manager.py index 760d0b0..e49a319 100644 --- a/linear_api/managers/issue_manager.py +++ b/linear_api/managers/issue_manager.py @@ -751,7 +751,7 @@ def get_comments(self, issue_id: str) -> List[Comment]: return comments def create_comment( - self, issue_id: str, body: str, parent_id: str = None + self, issue_id: str, body: str, username: str, parent_id: str = None ) -> Comment: """ Create a comment for an issue (or reply to an existing comment). @@ -778,7 +778,7 @@ def create_comment( } """ - input_vars = {"input": {"body": body, "issueId": issue_id}} + input_vars = {"input": {"body": body, "issueId": issue_id, "createAsUser": username if username is not None else ""}} if parent_id is not None: input_vars["input"]["parentId"] = parent_id