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