mcp-ticketer 0.1.39__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ """GraphQL client management for Linear API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, Dict, Optional
7
+
8
+ try:
9
+ from gql import Client, gql
10
+ from gql.transport.aiohttp import AIOHTTPTransport
11
+ from gql.transport.exceptions import TransportError
12
+ except ImportError:
13
+ # Handle missing gql dependency gracefully
14
+ Client = None
15
+ gql = None
16
+ AIOHTTPTransport = None
17
+ TransportError = Exception
18
+
19
+ from ...core.exceptions import AdapterError, AuthenticationError, RateLimitError
20
+
21
+
22
+ class LinearGraphQLClient:
23
+ """GraphQL client for Linear API with error handling and retry logic."""
24
+
25
+ def __init__(self, api_key: str, timeout: int = 30):
26
+ """Initialize the Linear GraphQL client.
27
+
28
+ Args:
29
+ api_key: Linear API key
30
+ timeout: Request timeout in seconds
31
+ """
32
+ self.api_key = api_key
33
+ self.timeout = timeout
34
+ self._base_url = "https://api.linear.app/graphql"
35
+
36
+ def create_client(self) -> "Client":
37
+ """Create a new GraphQL client instance.
38
+
39
+ Returns:
40
+ Configured GraphQL client
41
+
42
+ Raises:
43
+ AuthenticationError: If API key is invalid
44
+ AdapterError: If client creation fails
45
+ """
46
+ if Client is None:
47
+ raise AdapterError("gql library not installed. Install with: pip install gql[aiohttp]", "linear")
48
+
49
+ if not self.api_key:
50
+ raise AuthenticationError("Linear API key is required", "linear")
51
+
52
+ try:
53
+ # Create transport with authentication
54
+ transport = AIOHTTPTransport(
55
+ url=self._base_url,
56
+ headers={"Authorization": f"Bearer {self.api_key}"},
57
+ timeout=self.timeout,
58
+ )
59
+
60
+ # Create client
61
+ client = Client(transport=transport, fetch_schema_from_transport=False)
62
+ return client
63
+
64
+ except Exception as e:
65
+ raise AdapterError(f"Failed to create Linear client: {e}", "linear")
66
+
67
+ async def execute_query(
68
+ self,
69
+ query_string: str,
70
+ variables: Optional[Dict[str, Any]] = None,
71
+ retries: int = 3,
72
+ ) -> Dict[str, Any]:
73
+ """Execute a GraphQL query with error handling and retries.
74
+
75
+ Args:
76
+ query_string: GraphQL query string
77
+ variables: Query variables
78
+ retries: Number of retry attempts
79
+
80
+ Returns:
81
+ Query result data
82
+
83
+ Raises:
84
+ AuthenticationError: If authentication fails
85
+ RateLimitError: If rate limit is exceeded
86
+ AdapterError: If query execution fails
87
+ """
88
+ query = gql(query_string)
89
+
90
+ for attempt in range(retries + 1):
91
+ try:
92
+ client = self.create_client()
93
+ async with client as session:
94
+ result = await session.execute(query, variable_values=variables or {})
95
+ return result
96
+
97
+ except TransportError as e:
98
+ # Handle HTTP errors
99
+ if hasattr(e, 'response') and e.response:
100
+ status_code = e.response.status
101
+
102
+ if status_code == 401:
103
+ raise AuthenticationError("Invalid Linear API key", "linear")
104
+ elif status_code == 403:
105
+ raise AuthenticationError("Insufficient permissions", "linear")
106
+ elif status_code == 429:
107
+ # Rate limit exceeded
108
+ retry_after = e.response.headers.get("Retry-After", "60")
109
+ raise RateLimitError(
110
+ "Linear API rate limit exceeded",
111
+ "linear",
112
+ retry_after
113
+ )
114
+ elif status_code >= 500:
115
+ # Server error - retry
116
+ if attempt < retries:
117
+ await asyncio.sleep(2 ** attempt) # Exponential backoff
118
+ continue
119
+ raise AdapterError(f"Linear API server error: {status_code}", "linear")
120
+
121
+ # Network or other transport error
122
+ if attempt < retries:
123
+ await asyncio.sleep(2 ** attempt)
124
+ continue
125
+ raise AdapterError(f"Linear API transport error: {e}", "linear")
126
+
127
+ except Exception as e:
128
+ # GraphQL or other errors
129
+ error_msg = str(e)
130
+
131
+ # Check for specific GraphQL errors
132
+ if "authentication" in error_msg.lower() or "unauthorized" in error_msg.lower():
133
+ raise AuthenticationError(f"Linear authentication failed: {error_msg}", "linear")
134
+ elif "rate limit" in error_msg.lower():
135
+ raise RateLimitError("Linear API rate limit exceeded", "linear")
136
+
137
+ # Generic error
138
+ if attempt < retries:
139
+ await asyncio.sleep(2 ** attempt)
140
+ continue
141
+ raise AdapterError(f"Linear GraphQL error: {error_msg}", "linear")
142
+
143
+ # Should never reach here
144
+ raise AdapterError("Maximum retries exceeded", "linear")
145
+
146
+ async def execute_mutation(
147
+ self,
148
+ mutation_string: str,
149
+ variables: Optional[Dict[str, Any]] = None,
150
+ retries: int = 3,
151
+ ) -> Dict[str, Any]:
152
+ """Execute a GraphQL mutation with error handling.
153
+
154
+ Args:
155
+ mutation_string: GraphQL mutation string
156
+ variables: Mutation variables
157
+ retries: Number of retry attempts
158
+
159
+ Returns:
160
+ Mutation result data
161
+
162
+ Raises:
163
+ AuthenticationError: If authentication fails
164
+ RateLimitError: If rate limit is exceeded
165
+ AdapterError: If mutation execution fails
166
+ """
167
+ return await self.execute_query(mutation_string, variables, retries)
168
+
169
+ async def test_connection(self) -> bool:
170
+ """Test the connection to Linear API.
171
+
172
+ Returns:
173
+ True if connection is successful, False otherwise
174
+ """
175
+ try:
176
+ # Simple query to test authentication
177
+ test_query = """
178
+ query TestConnection {
179
+ viewer {
180
+ id
181
+ name
182
+ }
183
+ }
184
+ """
185
+
186
+ result = await self.execute_query(test_query)
187
+ return bool(result.get("viewer"))
188
+
189
+ except Exception:
190
+ return False
191
+
192
+ async def get_team_info(self, team_id: str) -> Optional[Dict[str, Any]]:
193
+ """Get team information by ID.
194
+
195
+ Args:
196
+ team_id: Linear team ID
197
+
198
+ Returns:
199
+ Team information or None if not found
200
+ """
201
+ try:
202
+ query = """
203
+ query GetTeam($teamId: String!) {
204
+ team(id: $teamId) {
205
+ id
206
+ name
207
+ key
208
+ description
209
+ }
210
+ }
211
+ """
212
+
213
+ result = await self.execute_query(query, {"teamId": team_id})
214
+ return result.get("team")
215
+
216
+ except Exception:
217
+ return None
218
+
219
+ async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
220
+ """Get user information by email.
221
+
222
+ Args:
223
+ email: User email address
224
+
225
+ Returns:
226
+ User information or None if not found
227
+ """
228
+ try:
229
+ query = """
230
+ query GetUserByEmail($email: String!) {
231
+ users(filter: { email: { eq: $email } }) {
232
+ nodes {
233
+ id
234
+ name
235
+ email
236
+ displayName
237
+ avatarUrl
238
+ }
239
+ }
240
+ }
241
+ """
242
+
243
+ result = await self.execute_query(query, {"email": email})
244
+ users = result.get("users", {}).get("nodes", [])
245
+ return users[0] if users else None
246
+
247
+ except Exception:
248
+ return None
249
+
250
+ async def close(self) -> None:
251
+ """Close the client connection.
252
+
253
+ Since we create fresh clients for each operation, there's no persistent
254
+ connection to close. Each client's transport is automatically closed when
255
+ the async context manager exits.
256
+ """
257
+ pass
@@ -0,0 +1,307 @@
1
+ """Data transformation mappers for Linear API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from ...core.models import Comment, Epic, Priority, Task, TicketState
9
+ from .types import (
10
+ extract_linear_metadata,
11
+ get_universal_priority,
12
+ get_universal_state,
13
+ )
14
+
15
+
16
+ def map_linear_issue_to_task(issue_data: Dict[str, Any]) -> Task:
17
+ """Convert Linear issue data to universal Task model.
18
+
19
+ Args:
20
+ issue_data: Raw Linear issue data from GraphQL
21
+
22
+ Returns:
23
+ Universal Task model
24
+ """
25
+ # Extract basic fields
26
+ task_id = issue_data["identifier"]
27
+ title = issue_data["title"]
28
+ description = issue_data.get("description", "")
29
+
30
+ # Map priority
31
+ linear_priority = issue_data.get("priority", 3)
32
+ priority = get_universal_priority(linear_priority)
33
+
34
+ # Map state
35
+ state_data = issue_data.get("state", {})
36
+ state_type = state_data.get("type", "unstarted")
37
+ state = get_universal_state(state_type)
38
+
39
+ # Extract assignee
40
+ assignee = None
41
+ if issue_data.get("assignee"):
42
+ assignee_data = issue_data["assignee"]
43
+ assignee = assignee_data.get("email") or assignee_data.get("displayName")
44
+
45
+ # Extract creator
46
+ creator = None
47
+ if issue_data.get("creator"):
48
+ creator_data = issue_data["creator"]
49
+ creator = creator_data.get("email") or creator_data.get("displayName")
50
+
51
+ # Extract tags (labels)
52
+ tags = []
53
+ if issue_data.get("labels", {}).get("nodes"):
54
+ tags = [label["name"] for label in issue_data["labels"]["nodes"]]
55
+
56
+ # Extract parent epic (project)
57
+ parent_epic = None
58
+ if issue_data.get("project"):
59
+ parent_epic = issue_data["project"]["id"]
60
+
61
+ # Extract parent issue
62
+ parent_issue = None
63
+ if issue_data.get("parent"):
64
+ parent_issue = issue_data["parent"]["identifier"]
65
+
66
+ # Extract dates
67
+ created_at = None
68
+ if issue_data.get("createdAt"):
69
+ created_at = datetime.fromisoformat(issue_data["createdAt"].replace("Z", "+00:00"))
70
+
71
+ updated_at = None
72
+ if issue_data.get("updatedAt"):
73
+ updated_at = datetime.fromisoformat(issue_data["updatedAt"].replace("Z", "+00:00"))
74
+
75
+ # Extract Linear-specific metadata
76
+ linear_metadata = extract_linear_metadata(issue_data)
77
+ metadata = {"linear": linear_metadata} if linear_metadata else None
78
+
79
+ return Task(
80
+ id=task_id,
81
+ title=title,
82
+ description=description,
83
+ state=state,
84
+ priority=priority,
85
+ assignee=assignee,
86
+ creator=creator,
87
+ tags=tags,
88
+ parent_epic=parent_epic,
89
+ parent_issue=parent_issue,
90
+ created_at=created_at,
91
+ updated_at=updated_at,
92
+ metadata=metadata,
93
+ )
94
+
95
+
96
+ def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
97
+ """Convert Linear project data to universal Epic model.
98
+
99
+ Args:
100
+ project_data: Raw Linear project data from GraphQL
101
+
102
+ Returns:
103
+ Universal Epic model
104
+ """
105
+ # Extract basic fields
106
+ epic_id = project_data["id"]
107
+ title = project_data["name"]
108
+ description = project_data.get("description", "")
109
+
110
+ # Map state based on project state
111
+ project_state = project_data.get("state", "planned")
112
+ if project_state == "completed":
113
+ state = TicketState.DONE
114
+ elif project_state == "started":
115
+ state = TicketState.IN_PROGRESS
116
+ elif project_state == "canceled":
117
+ state = TicketState.CLOSED
118
+ else:
119
+ state = TicketState.OPEN
120
+
121
+ # Extract dates
122
+ created_at = None
123
+ if project_data.get("createdAt"):
124
+ created_at = datetime.fromisoformat(project_data["createdAt"].replace("Z", "+00:00"))
125
+
126
+ updated_at = None
127
+ if project_data.get("updatedAt"):
128
+ updated_at = datetime.fromisoformat(project_data["updatedAt"].replace("Z", "+00:00"))
129
+
130
+ # Extract Linear-specific metadata
131
+ metadata = {"linear": {}}
132
+ if project_data.get("url"):
133
+ metadata["linear"]["linear_url"] = project_data["url"]
134
+ if project_data.get("icon"):
135
+ metadata["linear"]["icon"] = project_data["icon"]
136
+ if project_data.get("color"):
137
+ metadata["linear"]["color"] = project_data["color"]
138
+ if project_data.get("targetDate"):
139
+ metadata["linear"]["target_date"] = project_data["targetDate"]
140
+
141
+ return Epic(
142
+ id=epic_id,
143
+ title=title,
144
+ description=description,
145
+ state=state,
146
+ priority=Priority.MEDIUM, # Projects don't have priority in Linear
147
+ created_at=created_at,
148
+ updated_at=updated_at,
149
+ metadata=metadata if metadata["linear"] else None,
150
+ )
151
+
152
+
153
+ def map_linear_comment_to_comment(comment_data: Dict[str, Any], ticket_id: str) -> Comment:
154
+ """Convert Linear comment data to universal Comment model.
155
+
156
+ Args:
157
+ comment_data: Raw Linear comment data from GraphQL
158
+ ticket_id: ID of the ticket this comment belongs to
159
+
160
+ Returns:
161
+ Universal Comment model
162
+ """
163
+ # Extract basic fields
164
+ comment_id = comment_data["id"]
165
+ body = comment_data.get("body", "")
166
+
167
+ # Extract author
168
+ author = None
169
+ if comment_data.get("user"):
170
+ user_data = comment_data["user"]
171
+ author = user_data.get("email") or user_data.get("displayName")
172
+
173
+ # Extract dates
174
+ created_at = None
175
+ if comment_data.get("createdAt"):
176
+ created_at = datetime.fromisoformat(comment_data["createdAt"].replace("Z", "+00:00"))
177
+
178
+ updated_at = None
179
+ if comment_data.get("updatedAt"):
180
+ updated_at = datetime.fromisoformat(comment_data["updatedAt"].replace("Z", "+00:00"))
181
+
182
+ return Comment(
183
+ id=comment_id,
184
+ ticket_id=ticket_id,
185
+ body=body,
186
+ author=author,
187
+ created_at=created_at,
188
+ updated_at=updated_at,
189
+ )
190
+
191
+
192
+ def build_linear_issue_input(task: Task, team_id: str) -> Dict[str, Any]:
193
+ """Build Linear issue input from universal Task model.
194
+
195
+ Args:
196
+ task: Universal Task model
197
+ team_id: Linear team ID
198
+
199
+ Returns:
200
+ Linear issue input dictionary
201
+ """
202
+ from .types import get_linear_priority
203
+
204
+ issue_input = {
205
+ "title": task.title,
206
+ "teamId": team_id,
207
+ }
208
+
209
+ # Add description if provided
210
+ if task.description:
211
+ issue_input["description"] = task.description
212
+
213
+ # Add priority
214
+ if task.priority:
215
+ issue_input["priority"] = get_linear_priority(task.priority)
216
+
217
+ # Add assignee if provided (assumes it's a user ID)
218
+ if task.assignee:
219
+ issue_input["assigneeId"] = task.assignee
220
+
221
+ # Add parent issue if provided
222
+ if task.parent_issue:
223
+ issue_input["parentId"] = task.parent_issue
224
+
225
+ # Add project (epic) if provided
226
+ if task.parent_epic:
227
+ issue_input["projectId"] = task.parent_epic
228
+
229
+ # Add labels (tags) if provided
230
+ if task.tags:
231
+ # Note: Linear requires label IDs, not names
232
+ # This would need to be resolved by the adapter
233
+ pass
234
+
235
+ # Add Linear-specific metadata
236
+ if task.metadata and "linear" in task.metadata:
237
+ linear_meta = task.metadata["linear"]
238
+ if "due_date" in linear_meta:
239
+ issue_input["dueDate"] = linear_meta["due_date"]
240
+ if "cycle_id" in linear_meta:
241
+ issue_input["cycleId"] = linear_meta["cycle_id"]
242
+ if "estimate" in linear_meta:
243
+ issue_input["estimate"] = linear_meta["estimate"]
244
+
245
+ return issue_input
246
+
247
+
248
+ def build_linear_issue_update_input(updates: Dict[str, Any]) -> Dict[str, Any]:
249
+ """Build Linear issue update input from update dictionary.
250
+
251
+ Args:
252
+ updates: Dictionary of fields to update
253
+
254
+ Returns:
255
+ Linear issue update input dictionary
256
+ """
257
+ from .types import get_linear_priority
258
+
259
+ update_input = {}
260
+
261
+ # Map standard fields
262
+ if "title" in updates:
263
+ update_input["title"] = updates["title"]
264
+
265
+ if "description" in updates:
266
+ update_input["description"] = updates["description"]
267
+
268
+ if "priority" in updates:
269
+ priority = Priority(updates["priority"]) if isinstance(updates["priority"], str) else updates["priority"]
270
+ update_input["priority"] = get_linear_priority(priority)
271
+
272
+ if "assignee" in updates:
273
+ update_input["assigneeId"] = updates["assignee"]
274
+
275
+ # Handle state transitions (would need workflow state mapping)
276
+ if "state" in updates:
277
+ # This would need to be handled by the adapter with proper state mapping
278
+ pass
279
+
280
+ # Handle metadata updates
281
+ if "metadata" in updates and "linear" in updates["metadata"]:
282
+ linear_meta = updates["metadata"]["linear"]
283
+ if "due_date" in linear_meta:
284
+ update_input["dueDate"] = linear_meta["due_date"]
285
+ if "cycle_id" in linear_meta:
286
+ update_input["cycleId"] = linear_meta["cycle_id"]
287
+ if "project_id" in linear_meta:
288
+ update_input["projectId"] = linear_meta["project_id"]
289
+ if "estimate" in linear_meta:
290
+ update_input["estimate"] = linear_meta["estimate"]
291
+
292
+ return update_input
293
+
294
+
295
+ def extract_child_issue_ids(issue_data: Dict[str, Any]) -> List[str]:
296
+ """Extract child issue IDs from Linear issue data.
297
+
298
+ Args:
299
+ issue_data: Raw Linear issue data from GraphQL
300
+
301
+ Returns:
302
+ List of child issue identifiers
303
+ """
304
+ child_ids = []
305
+ if issue_data.get("children", {}).get("nodes"):
306
+ child_ids = [child["identifier"] for child in issue_data["children"]["nodes"]]
307
+ return child_ids