diff --git a/linear_api/managers/issue_manager.py b/linear_api/managers/issue_manager.py index e034fd4..adba6ff 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,54 @@ 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) + def get_children(self, issue_id: str) -> Dict[str, LinearIssue]: """ Get child issues for an issue. @@ -777,9 +828,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 +879,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 +946,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 +992,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 +1028,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 +1053,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 +1115,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 +1166,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 +1175,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 +1227,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 +1254,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 +1268,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 +1287,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() diff --git a/linear_api/managers/project_manager.py b/linear_api/managers/project_manager.py index 1bf04f8..4c82083 100644 --- a/linear_api/managers/project_manager.py +++ b/linear_api/managers/project_manager.py @@ -5,15 +5,28 @@ """ from datetime import datetime -from typing import Dict, Optional, List, Any +from typing import Any, Dict, List, Optional -from .base_manager import BaseManager from ..domain import ( - LinearProject, ProjectStatus, FrequencyResolutionType, ProjectStatusType, - LinearUser, ProjectMilestone, Comment, LinearTeam, ProjectUpdate, - Document, EntityExternalLink, LinearLabel, CustomerNeed, ProjectRelation, ProjectHistory, LinearIssue + Comment, + CustomerNeed, + Document, + EntityExternalLink, + FrequencyResolutionType, + LinearIssue, + LinearLabel, + LinearProject, + LinearTeam, + LinearUser, + ProjectHistory, + ProjectMilestone, + ProjectRelation, + ProjectStatus, + ProjectStatusType, + ProjectUpdate, ) -from ..utils import process_project_data, enrich_with_client +from ..utils import enrich_with_client, process_project_data +from .base_manager import BaseManager class ProjectManager(BaseManager[LinearProject]): @@ -148,7 +161,9 @@ def get(self, project_id: str) -> LinearProject: return project @enrich_with_client - def create(self, name: str, team_name: str, description: Optional[str] = None) -> LinearProject: + def create( + self, name: str, team_name: str, description: Optional[str] = None + ) -> LinearProject: """ Create a new project in Linear. @@ -193,7 +208,9 @@ def create(self, name: str, team_name: str, description: Optional[str] = None) - # Invalidate caches after creation self._cache_clear() - self.client.teams._cache_invalidate("all_teams", "all") # Also invalidate team cache + self.client.teams._cache_invalidate( + "all_teams", "all" + ) # Also invalidate team cache # Return the full project object project_id = response["projectCreate"]["project"]["id"] @@ -229,7 +246,9 @@ def update(self, project_id: str, **kwargs) -> LinearProject: } """ - response = self._execute_query(update_project_mutation, {"id": project_id, "input": kwargs}) + response = self._execute_query( + update_project_mutation, {"id": project_id, "input": kwargs} + ) if not response or not response.get("projectUpdate", {}).get("success", False): raise ValueError(f"Failed to update project with ID: {project_id}") @@ -317,9 +336,7 @@ def get_all(self, team_id: Optional[str] = None) -> Dict[str, LinearProject]: return {} project_nodes = self._handle_pagination( - query, - {"teamId": team_id}, - ["team", "projects", "nodes"] + query, {"teamId": team_id}, ["team", "projects", "nodes"] ) else: query = """ @@ -339,11 +356,7 @@ def get_all(self, team_id: Optional[str] = None) -> Dict[str, LinearProject]: """ # Get projects using our improved helper method - project_nodes = self._handle_pagination( - query, - {}, - ["projects", "nodes"] - ) + project_nodes = self._handle_pagination(query, {}, ["projects", "nodes"]) # Create basic LinearProject objects without requesting all details projects = {} @@ -354,21 +367,23 @@ def get_all(self, team_id: Optional[str] = None) -> Dict[str, LinearProject]: for project_data in project_nodes: try: # Add required fields with default values - project_data.update({ - "createdAt": current_time, - "updatedAt": current_time, - "slugId": "default-slug", - "url": f"https://linear.app/project/{project_data['id']}", - "color": "#000000", - "priority": 0, - "priorityLabel": "None", - "prioritySortOrder": 0.0, - "sortOrder": 0.0, - "progress": 0.0, - "status": {"type": ProjectStatusType.PLANNED}, - "scope": 0.0, - "frequencyResolution": FrequencyResolutionType.WEEKLY - }) + project_data.update( + { + "createdAt": current_time, + "updatedAt": current_time, + "slugId": "default-slug", + "url": f"https://linear.app/project/{project_data['id']}", + "color": "#000000", + "priority": 0, + "priorityLabel": "None", + "prioritySortOrder": 0.0, + "sortOrder": 0.0, + "progress": 0.0, + "status": {"type": ProjectStatusType.PLANNED}, + "scope": 0.0, + "frequencyResolution": FrequencyResolutionType.WEEKLY, + } + ) project_data["status"] = ProjectStatus(**project_data["status"]) @@ -379,7 +394,9 @@ def get_all(self, team_id: Optional[str] = None) -> Dict[str, LinearProject]: self._cache_set("projects_by_id", project.id, project) # Cache project ID by name - self._cache_set("project_ids_by_name", (project.name, team_id), project.id) + self._cache_set( + "project_ids_by_name", (project.name, team_id), project.id + ) except Exception as e: print(f"Error creating project from data {project_data}: {e}") @@ -434,9 +451,7 @@ def get_id_by_name(self, project_name: str, team_id: Optional[str] = None) -> st raise ValueError(f"Team with ID {team_id} not found") projects = self._handle_pagination( - query, - {"teamId": team_id}, - ["team", "projects", "nodes"] + query, {"teamId": team_id}, ["team", "projects", "nodes"] ) else: query = """ @@ -455,16 +470,14 @@ def get_id_by_name(self, project_name: str, team_id: Optional[str] = None) -> st """ # Get the projects using our improved helper method - projects = self._handle_pagination( - query, - {}, - ["projects", "nodes"] - ) + projects = self._handle_pagination(query, {}, ["projects", "nodes"]) # Cache all project IDs by name for project in projects: if "name" in project and "id" in project: - self._cache_set("project_ids_by_name", (project["name"], team_id), project["id"]) + self._cache_set( + "project_ids_by_name", (project["name"], team_id), project["id"] + ) # Check cache again after populating it cached_id = self._cache_get("project_ids_by_name", cache_key) @@ -522,7 +535,7 @@ def get_members(self, project_id: str) -> List[LinearUser]: query, {"projectId": project_id}, ["project", "members", "nodes"], - LinearUser # Pass the model class for automatic conversion + LinearUser, # Pass the model class for automatic conversion ) # Cache the result @@ -572,7 +585,7 @@ def get_milestones(self, project_id: str) -> List[ProjectMilestone]: query, {"projectId": project_id}, ["project", "projectMilestones", "nodes"], - ProjectMilestone # Pass the model class for automatic conversion + ProjectMilestone, # Pass the model class for automatic conversion ) # Cache the result @@ -624,7 +637,7 @@ def get_comments(self, project_id: str) -> List[Comment]: query, {"projectId": project_id}, ["project", "comments", "nodes"], - Comment # Pass the model class for automatic conversion + Comment, # Pass the model class for automatic conversion ) # Cache the result @@ -632,6 +645,55 @@ def get_comments(self, project_id: str) -> List[Comment]: return comments + def create_comment( + self, project_id: str, body: str, parent_id: str = None + ) -> Comment: + """ + Create a comment for a project (or reply to an existing comment). + + Args: + project_id: The ID of the project 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 + """ + # Build mutation + mutation = """ + mutation CreateComment($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + body + createdAt + updatedAt + } + } + } + """ + + input_vars = {"input": {"body": body, "projectId": project_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 project {project_id}") + + comment_data = response["commentCreate"]["comment"] + + # Invalidate cache for project comments + self._cache_invalidate("comments_by_project", project_id) + + return Comment(**comment_data) + @enrich_with_client def get_issues(self, project_id: str) -> List[LinearIssue]: """ @@ -682,10 +744,7 @@ def get_issues(self, project_id: str) -> List[LinearIssue]: return [] issues = self._extract_and_cache( - response, - ["project", "issues"], - "issues_by_project", - project_id + response, ["project", "issues"], "issues_by_project", project_id ) return issues @@ -731,7 +790,7 @@ def get_project_updates(self, project_id: str) -> List[ProjectUpdate]: query, {"projectId": project_id}, ["project", "projectUpdates", "nodes"], - ProjectUpdate # Pass the model class for automatic conversion + ProjectUpdate, # Pass the model class for automatic conversion ) # Cache the result @@ -794,7 +853,7 @@ def get_relations(self, project_id: str) -> List[ProjectRelation]: query, {"projectId": project_id}, ["project", "relations", "nodes"], - ProjectRelation + ProjectRelation, ) # Cache the result @@ -847,7 +906,7 @@ def get_teams(self, project_id: str) -> List[LinearTeam]: query, {"projectId": project_id}, ["project", "teams", "nodes"], - LinearTeam # Pass the model class for automatic conversion + LinearTeam, # Pass the model class for automatic conversion ) # Cache the result @@ -900,7 +959,7 @@ def get_documents(self, project_id: str) -> List[Document]: query, {"projectId": project_id}, ["project", "documents", "nodes"], - Document # Pass the model class for automatic conversion + Document, # Pass the model class for automatic conversion ) # Cache the result @@ -952,7 +1011,7 @@ def get_external_links(self, project_id: str) -> List[EntityExternalLink]: query, {"projectId": project_id}, ["project", "externalLinks", "nodes"], - EntityExternalLink # Pass the model class for automatic conversion + EntityExternalLink, # Pass the model class for automatic conversion ) # Cache the result @@ -1004,7 +1063,7 @@ def get_history(self, project_id: str) -> List[ProjectHistory]: query, {"projectId": project_id}, ["project", "history", "nodes"], - ProjectHistory + ProjectHistory, ) # Cache the result @@ -1054,10 +1113,7 @@ def get_initiatives(self, project_id: str) -> List[Dict[str, Any]]: return [] initiatives = self._extract_and_cache( - response, - ["project", "initiatives"], - "initiatives_by_project", - project_id + response, ["project", "initiatives"], "initiatives_by_project", project_id ) return initiatives @@ -1108,7 +1164,7 @@ def get_labels(self, project_id: str) -> List[LinearLabel]: query, {"projectId": project_id}, ["project", "labels", "nodes"], - LinearLabel # Pass the model class for automatic conversion + LinearLabel, # Pass the model class for automatic conversion ) # Cache the result @@ -1168,7 +1224,7 @@ def get_needs(self, project_id: str) -> List[CustomerNeed]: query, {"projectId": project_id}, ["project", "needs", "nodes"], - CustomerNeed + CustomerNeed, ) # Cache the result diff --git a/tests/test_issue_manager.py b/tests/test_issue_manager.py index a3d6e0d..def6c61 100644 --- a/tests/test_issue_manager.py +++ b/tests/test_issue_manager.py @@ -4,11 +4,12 @@ This module tests the functionality of the IssueManager class. """ -import pytest import time import uuid from datetime import datetime, timedelta +import pytest + from linear_api import LinearClient from linear_api.domain import ( LinearIssue, @@ -25,6 +26,7 @@ def client(): """Create a LinearClient instance for testing.""" # Get the API key from environment variable import os + api_key = os.getenv("LINEAR_API_KEY") if not api_key: pytest.skip("LINEAR_API_KEY environment variable not set") @@ -162,7 +164,7 @@ def test_get_by_team(client, test_team_name): try: # Create 3 test issues with different priorities for i, priority in enumerate( - [LinearPriority.HIGH, LinearPriority.MEDIUM, LinearPriority.LOW] + [LinearPriority.HIGH, LinearPriority.MEDIUM, LinearPriority.LOW] ): unique_id = str(uuid.uuid4())[:8] issue_input = LinearIssueInput( @@ -295,7 +297,7 @@ def test_create_issue_with_additional_fields(client, test_team_name): prioritySortOrder=50.0, slaType=SLADayCountType.ALL, dueDate=datetime.now() + timedelta(days=7), - metadata={"test_type": "additional_fields", "automated": True} + metadata={"test_type": "additional_fields", "automated": True}, ) issue = client.issues.create(issue_input) @@ -330,6 +332,34 @@ def test_get_comments(client, test_issue): assert isinstance(comments, list) +def test_create_comment_and_reply(client, test_issue): + """Create a comment for an issue and a reply to that comment.""" + import uuid + + # Create the original comment + body = f"Test comment {str(uuid.uuid4())[:8]}" + comment = client.issues.create_comment(test_issue.id, body) + + # Minimal checks for expected fields + assert hasattr(comment, "id") + assert comment.body == body + + # Create a reply to the comment + reply_body = f"Reply to comment {str(uuid.uuid4())[:8]}" + reply = client.issues.create_comment( + test_issue.id, reply_body, parent_id=comment.id + ) + + assert hasattr(reply, "id") + assert reply.body == reply_body + + # Ensure both comments are visible in the comments list + comments = client.issues.get_comments(test_issue.id) + ids = {c.id for c in comments} + assert comment.id in ids + assert reply.id in ids + + def test_get_history(client, test_issue): """Test getting history for an issue.""" # Get history for the issue @@ -349,7 +379,7 @@ def test_get_issue_children(client, test_issue): teamName=test_issue.team.name, description="This is a child issue created for testing get_children", priority=LinearPriority.MEDIUM, - parentId=test_issue.id + parentId=test_issue.id, ) child_issue = client.issues.create(child_input) @@ -386,9 +416,9 @@ def test_get_issue_subscribers(client, test_issue): # Verify each subscriber is a LinearUser instance for subscriber in subscribers: - assert hasattr(subscriber, 'id') - assert hasattr(subscriber, 'name') - assert hasattr(subscriber, 'email') + assert hasattr(subscriber, "id") + assert hasattr(subscriber, "name") + assert hasattr(subscriber, "email") def test_get_reactions(client, test_issue): diff --git a/tests/test_project_manager.py b/tests/test_project_manager.py index 02521d0..a90f452 100644 --- a/tests/test_project_manager.py +++ b/tests/test_project_manager.py @@ -4,12 +4,13 @@ This module tests the functionality of the ProjectManager class. """ -import pytest import time import uuid +import pytest + from linear_api import LinearClient, LinearTeam -from linear_api.domain import LinearProject, ProjectMilestone, Comment +from linear_api.domain import Comment, LinearProject, ProjectMilestone @pytest.fixture @@ -17,6 +18,7 @@ def client(): """Create a LinearClient instance for testing.""" # Get the API key from environment variable import os + api_key = os.getenv("LINEAR_API_KEY") if not api_key: pytest.skip("LINEAR_API_KEY environment variable not set") @@ -41,7 +43,7 @@ def test_project(client, test_team_name): project = client.projects.create( name=project_name, team_name=test_team_name, - description="This is a test project created by automated tests" + description="This is a test project created by automated tests", ) # Return the project for use in tests @@ -78,14 +80,16 @@ def test_create_project(client, test_team_name): project = client.projects.create( name=unique_name, team_name=test_team_name, - description="This is a test project for testing project creation" + description="This is a test project for testing project creation", ) try: # Verify the project was created with the correct properties assert project is not None assert project.name == unique_name - assert project.description == "This is a test project for testing project creation" + assert ( + project.description == "This is a test project for testing project creation" + ) finally: # Clean up - delete the project client.projects.delete(project.id) @@ -99,9 +103,7 @@ def test_update_project(client, test_project): # Update the project updated_project = client.projects.update( - test_project.id, - name=new_name, - description=new_description + test_project.id, name=new_name, description=new_description ) # Verify the project was updated @@ -116,7 +118,7 @@ def test_delete_project(client, test_team_name): project = client.projects.create( name=f"Project to Delete {str(uuid.uuid4())[:8]}", team_name=test_team_name, - description="This project will be deleted" + description="This project will be deleted", ) # Delete the project @@ -137,7 +139,7 @@ def test_get_all_projects(client, test_team_name): project = client.projects.create( name=f"Test All Projects {str(uuid.uuid4())[:8]}", team_name=test_team_name, - description="This project is for testing get_all" + description="This project is for testing get_all", ) try: @@ -167,7 +169,7 @@ def test_get_projects_by_team(client, test_team_name): project = client.projects.create( name=f"Test Team Projects {str(uuid.uuid4())[:8]}", team_name=test_team_name, - description="This project is for testing get_all with team filter" + description="This project is for testing get_all with team filter", ) try: @@ -201,10 +203,7 @@ def test_create_project_with_invalid_team(client): """Test creating a project with an invalid team name.""" # Try to create a project with a non-existent team with pytest.raises(ValueError): - client.projects.create( - name="Invalid Team Project", - team_name="NonExistentTeam" - ) + client.projects.create(name="Invalid Team Project", team_name="NonExistentTeam") def test_delete_nonexistent_project(client): @@ -228,10 +227,10 @@ def test_get_project_members(client, test_project): # Verify each member is a LinearUser instance for member in members: - assert hasattr(member, 'id') - assert hasattr(member, 'name') - assert hasattr(member, 'email') - assert hasattr(member, 'displayName') + assert hasattr(member, "id") + assert hasattr(member, "name") + assert hasattr(member, "email") + assert hasattr(member, "displayName") def test_get_project_milestones(client, test_project): @@ -263,8 +262,8 @@ def test_get_milestones(client, test_project): # If milestones exist, check their structure for milestone in milestones: assert isinstance(milestone, ProjectMilestone) - assert hasattr(milestone, 'id') - assert hasattr(milestone, 'name') + assert hasattr(milestone, "id") + assert hasattr(milestone, "name") def test_get_comments(client, test_project): @@ -278,9 +277,37 @@ def test_get_comments(client, test_project): # If comments exist, check their structure for comment in comments: assert isinstance(comment, Comment) - assert hasattr(comment, 'id') - assert hasattr(comment, 'body') - assert hasattr(comment, 'createdAt') + assert hasattr(comment, "id") + assert hasattr(comment, "body") + assert hasattr(comment, "createdAt") + + +def test_create_comment_and_reply_project(client, test_project): + """Create a comment for a project and a reply to that comment.""" + import uuid + + # Create the original comment + body = f"Test project comment {str(uuid.uuid4())[:8]}" + comment = client.projects.create_comment(test_project.id, body) + + # Basic checks + assert hasattr(comment, "id") + assert comment.body == body + + # Create a reply to the comment + reply_body = f"Reply to project comment {str(uuid.uuid4())[:8]}" + reply = client.projects.create_comment( + test_project.id, reply_body, parent_id=comment.id + ) + + assert hasattr(reply, "id") + assert reply.body == reply_body + + # Verify both comments are visible in the comments list + comments = client.projects.get_comments(test_project.id) + ids = {c.id for c in comments} + assert comment.id in ids + assert reply.id in ids def test_get_relations(client, test_project): @@ -293,9 +320,9 @@ def test_get_relations(client, test_project): # If relations exist, check their structure for relation in relations: - assert 'id' in relation - assert 'type' in relation - assert 'targetId' in relation + assert "id" in relation + assert "type" in relation + assert "targetId" in relation def test_get_teams(client, test_project): @@ -310,8 +337,8 @@ def test_get_teams(client, test_project): # Check each team for team in teams: assert isinstance(team, LinearTeam) - assert hasattr(team, 'id') - assert hasattr(team, 'name') + assert hasattr(team, "id") + assert hasattr(team, "name") def test_get_documents(client, test_project): @@ -345,11 +372,11 @@ def test_get_history(client, test_project): # Check structure of history items if any exist for item in history: - assert hasattr(item, 'id') - assert hasattr(item, 'createdAt') + assert hasattr(item, "id") + assert hasattr(item, "createdAt") # Check entries if present - if hasattr(item, 'entries') and item.entries: + if hasattr(item, "entries") and item.entries: assert isinstance(item.entries, dict)