mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__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/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- mcp_ticketer/core/session_state.py +171 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -7,7 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
9
|
from gql import Client, gql
|
|
10
|
-
from gql.transport.exceptions import TransportError
|
|
10
|
+
from gql.transport.exceptions import TransportError, TransportQueryError
|
|
11
11
|
from gql.transport.httpx import HTTPXAsyncTransport
|
|
12
12
|
except ImportError:
|
|
13
13
|
# Handle missing gql dependency gracefully
|
|
@@ -15,6 +15,7 @@ except ImportError:
|
|
|
15
15
|
gql = None
|
|
16
16
|
HTTPXAsyncTransport = None
|
|
17
17
|
TransportError = Exception
|
|
18
|
+
TransportQueryError = Exception
|
|
18
19
|
|
|
19
20
|
from ...core.exceptions import AdapterError, AuthenticationError, RateLimitError
|
|
20
21
|
|
|
@@ -26,6 +27,7 @@ class LinearGraphQLClient:
|
|
|
26
27
|
"""Initialize the Linear GraphQL client.
|
|
27
28
|
|
|
28
29
|
Args:
|
|
30
|
+
----
|
|
29
31
|
api_key: Linear API key
|
|
30
32
|
timeout: Request timeout in seconds
|
|
31
33
|
|
|
@@ -38,9 +40,11 @@ class LinearGraphQLClient:
|
|
|
38
40
|
"""Create a new GraphQL client instance.
|
|
39
41
|
|
|
40
42
|
Returns:
|
|
43
|
+
-------
|
|
41
44
|
Configured GraphQL client
|
|
42
45
|
|
|
43
46
|
Raises:
|
|
47
|
+
------
|
|
44
48
|
AuthenticationError: If API key is invalid
|
|
45
49
|
AdapterError: If client creation fails
|
|
46
50
|
|
|
@@ -69,7 +73,7 @@ class LinearGraphQLClient:
|
|
|
69
73
|
return client
|
|
70
74
|
|
|
71
75
|
except Exception as e:
|
|
72
|
-
raise AdapterError(f"Failed to create Linear client: {e}", "linear")
|
|
76
|
+
raise AdapterError(f"Failed to create Linear client: {e}", "linear") from e
|
|
73
77
|
|
|
74
78
|
async def execute_query(
|
|
75
79
|
self,
|
|
@@ -80,14 +84,17 @@ class LinearGraphQLClient:
|
|
|
80
84
|
"""Execute a GraphQL query with error handling and retries.
|
|
81
85
|
|
|
82
86
|
Args:
|
|
87
|
+
----
|
|
83
88
|
query_string: GraphQL query string
|
|
84
89
|
variables: Query variables
|
|
85
90
|
retries: Number of retry attempts
|
|
86
91
|
|
|
87
92
|
Returns:
|
|
93
|
+
-------
|
|
88
94
|
Query result data
|
|
89
95
|
|
|
90
96
|
Raises:
|
|
97
|
+
------
|
|
91
98
|
AuthenticationError: If authentication fails
|
|
92
99
|
RateLimitError: If rate limit is exceeded
|
|
93
100
|
AdapterError: If query execution fails
|
|
@@ -104,21 +111,52 @@ class LinearGraphQLClient:
|
|
|
104
111
|
)
|
|
105
112
|
return result
|
|
106
113
|
|
|
114
|
+
except TransportQueryError as e:
|
|
115
|
+
"""
|
|
116
|
+
Handle GraphQL validation errors (e.g., duplicate label names).
|
|
117
|
+
TransportQueryError is a subclass of TransportError with .errors attribute.
|
|
118
|
+
|
|
119
|
+
Related: 1M-398 - Label duplicate error handling
|
|
120
|
+
"""
|
|
121
|
+
if e.errors:
|
|
122
|
+
error_msg = e.errors[0].get("message", "Unknown GraphQL error")
|
|
123
|
+
|
|
124
|
+
# Check for duplicate label errors specifically
|
|
125
|
+
if (
|
|
126
|
+
"duplicate" in error_msg.lower()
|
|
127
|
+
and "label" in error_msg.lower()
|
|
128
|
+
):
|
|
129
|
+
raise AdapterError(
|
|
130
|
+
f"Label already exists: {error_msg}", "linear"
|
|
131
|
+
) from e
|
|
132
|
+
|
|
133
|
+
# Other validation errors
|
|
134
|
+
raise AdapterError(
|
|
135
|
+
f"Linear GraphQL validation error: {error_msg}", "linear"
|
|
136
|
+
) from e
|
|
137
|
+
|
|
138
|
+
# Fallback if no errors attribute
|
|
139
|
+
raise AdapterError(f"Linear GraphQL error: {e}", "linear") from e
|
|
140
|
+
|
|
107
141
|
except TransportError as e:
|
|
108
142
|
# Handle HTTP errors
|
|
109
143
|
if hasattr(e, "response") and e.response:
|
|
110
144
|
status_code = e.response.status
|
|
111
145
|
|
|
112
146
|
if status_code == 401:
|
|
113
|
-
raise AuthenticationError(
|
|
147
|
+
raise AuthenticationError(
|
|
148
|
+
"Invalid Linear API key", "linear"
|
|
149
|
+
) from e
|
|
114
150
|
elif status_code == 403:
|
|
115
|
-
raise AuthenticationError(
|
|
151
|
+
raise AuthenticationError(
|
|
152
|
+
"Insufficient permissions", "linear"
|
|
153
|
+
) from e
|
|
116
154
|
elif status_code == 429:
|
|
117
155
|
# Rate limit exceeded
|
|
118
156
|
retry_after = e.response.headers.get("Retry-After", "60")
|
|
119
157
|
raise RateLimitError(
|
|
120
158
|
"Linear API rate limit exceeded", "linear", retry_after
|
|
121
|
-
)
|
|
159
|
+
) from e
|
|
122
160
|
elif status_code >= 500:
|
|
123
161
|
# Server error - retry
|
|
124
162
|
if attempt < retries:
|
|
@@ -126,13 +164,13 @@ class LinearGraphQLClient:
|
|
|
126
164
|
continue
|
|
127
165
|
raise AdapterError(
|
|
128
166
|
f"Linear API server error: {status_code}", "linear"
|
|
129
|
-
)
|
|
167
|
+
) from e
|
|
130
168
|
|
|
131
169
|
# Network or other transport error
|
|
132
170
|
if attempt < retries:
|
|
133
171
|
await asyncio.sleep(2**attempt)
|
|
134
172
|
continue
|
|
135
|
-
raise AdapterError(f"Linear API transport error: {e}", "linear")
|
|
173
|
+
raise AdapterError(f"Linear API transport error: {e}", "linear") from e
|
|
136
174
|
|
|
137
175
|
except Exception as e:
|
|
138
176
|
# GraphQL or other errors
|
|
@@ -145,15 +183,19 @@ class LinearGraphQLClient:
|
|
|
145
183
|
):
|
|
146
184
|
raise AuthenticationError(
|
|
147
185
|
f"Linear authentication failed: {error_msg}", "linear"
|
|
148
|
-
)
|
|
186
|
+
) from e
|
|
149
187
|
elif "rate limit" in error_msg.lower():
|
|
150
|
-
raise RateLimitError(
|
|
188
|
+
raise RateLimitError(
|
|
189
|
+
"Linear API rate limit exceeded", "linear"
|
|
190
|
+
) from e
|
|
151
191
|
|
|
152
192
|
# Generic error
|
|
153
193
|
if attempt < retries:
|
|
154
194
|
await asyncio.sleep(2**attempt)
|
|
155
195
|
continue
|
|
156
|
-
raise AdapterError(
|
|
196
|
+
raise AdapterError(
|
|
197
|
+
f"Linear GraphQL error: {error_msg}", "linear"
|
|
198
|
+
) from e
|
|
157
199
|
|
|
158
200
|
# Should never reach here
|
|
159
201
|
raise AdapterError("Maximum retries exceeded", "linear")
|
|
@@ -167,14 +209,17 @@ class LinearGraphQLClient:
|
|
|
167
209
|
"""Execute a GraphQL mutation with error handling.
|
|
168
210
|
|
|
169
211
|
Args:
|
|
212
|
+
----
|
|
170
213
|
mutation_string: GraphQL mutation string
|
|
171
214
|
variables: Mutation variables
|
|
172
215
|
retries: Number of retry attempts
|
|
173
216
|
|
|
174
217
|
Returns:
|
|
218
|
+
-------
|
|
175
219
|
Mutation result data
|
|
176
220
|
|
|
177
221
|
Raises:
|
|
222
|
+
------
|
|
178
223
|
AuthenticationError: If authentication fails
|
|
179
224
|
RateLimitError: If rate limit is exceeded
|
|
180
225
|
AdapterError: If mutation execution fails
|
|
@@ -186,9 +231,20 @@ class LinearGraphQLClient:
|
|
|
186
231
|
"""Test the connection to Linear API.
|
|
187
232
|
|
|
188
233
|
Returns:
|
|
234
|
+
-------
|
|
189
235
|
True if connection is successful, False otherwise
|
|
190
236
|
|
|
237
|
+
Design Decision: Enhanced Debug Logging (1M-431)
|
|
238
|
+
-------------------------------------------------
|
|
239
|
+
Added comprehensive logging to diagnose connection failures.
|
|
240
|
+
Logs API key preview, query results, and specific failure reasons
|
|
241
|
+
to help users troubleshoot authentication and configuration issues.
|
|
242
|
+
|
|
191
243
|
"""
|
|
244
|
+
import logging
|
|
245
|
+
|
|
246
|
+
logger = logging.getLogger(__name__)
|
|
247
|
+
|
|
192
248
|
try:
|
|
193
249
|
# Simple query to test authentication
|
|
194
250
|
test_query = """
|
|
@@ -196,23 +252,53 @@ class LinearGraphQLClient:
|
|
|
196
252
|
viewer {
|
|
197
253
|
id
|
|
198
254
|
name
|
|
255
|
+
email
|
|
199
256
|
}
|
|
200
257
|
}
|
|
201
258
|
"""
|
|
202
259
|
|
|
260
|
+
logger.debug(
|
|
261
|
+
f"Testing Linear API connection with API key: {self.api_key[:20]}..."
|
|
262
|
+
)
|
|
203
263
|
result = await self.execute_query(test_query)
|
|
204
|
-
return bool(result.get("viewer"))
|
|
205
264
|
|
|
206
|
-
|
|
265
|
+
# Log the actual response for debugging
|
|
266
|
+
logger.debug(f"Linear API test response: {result}")
|
|
267
|
+
|
|
268
|
+
viewer = result.get("viewer")
|
|
269
|
+
|
|
270
|
+
if not viewer:
|
|
271
|
+
logger.warning(
|
|
272
|
+
f"Linear test connection query succeeded but returned no viewer data. "
|
|
273
|
+
f"Response: {result}"
|
|
274
|
+
)
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
if not viewer.get("id"):
|
|
278
|
+
logger.warning(f"Linear viewer missing id field. Viewer data: {viewer}")
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
logger.info(
|
|
282
|
+
f"Linear API connected successfully as: {viewer.get('name')} ({viewer.get('email')})"
|
|
283
|
+
)
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.error(
|
|
288
|
+
f"Linear connection test failed: {type(e).__name__}: {e}",
|
|
289
|
+
exc_info=True,
|
|
290
|
+
)
|
|
207
291
|
return False
|
|
208
292
|
|
|
209
293
|
async def get_team_info(self, team_id: str) -> dict[str, Any] | None:
|
|
210
294
|
"""Get team information by ID.
|
|
211
295
|
|
|
212
296
|
Args:
|
|
297
|
+
----
|
|
213
298
|
team_id: Linear team ID
|
|
214
299
|
|
|
215
300
|
Returns:
|
|
301
|
+
-------
|
|
216
302
|
Team information or None if not found
|
|
217
303
|
|
|
218
304
|
"""
|
|
@@ -238,9 +324,11 @@ class LinearGraphQLClient:
|
|
|
238
324
|
"""Get user information by email.
|
|
239
325
|
|
|
240
326
|
Args:
|
|
327
|
+
----
|
|
241
328
|
email: User email address
|
|
242
329
|
|
|
243
330
|
Returns:
|
|
331
|
+
-------
|
|
244
332
|
User information or None if not found
|
|
245
333
|
|
|
246
334
|
"""
|
|
@@ -266,6 +354,52 @@ class LinearGraphQLClient:
|
|
|
266
354
|
except Exception:
|
|
267
355
|
return None
|
|
268
356
|
|
|
357
|
+
async def get_users_by_name(self, name: str) -> list[dict[str, Any]]:
|
|
358
|
+
"""Search users by display name or full name.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
----
|
|
362
|
+
name: Display name or full name to search for
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
-------
|
|
366
|
+
List of matching users (may be empty)
|
|
367
|
+
|
|
368
|
+
"""
|
|
369
|
+
import logging
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
query = """
|
|
373
|
+
query SearchUsers($nameFilter: String!) {
|
|
374
|
+
users(
|
|
375
|
+
filter: {
|
|
376
|
+
or: [
|
|
377
|
+
{ displayName: { containsIgnoreCase: $nameFilter } }
|
|
378
|
+
{ name: { containsIgnoreCase: $nameFilter } }
|
|
379
|
+
]
|
|
380
|
+
}
|
|
381
|
+
first: 10
|
|
382
|
+
) {
|
|
383
|
+
nodes {
|
|
384
|
+
id
|
|
385
|
+
name
|
|
386
|
+
email
|
|
387
|
+
displayName
|
|
388
|
+
avatarUrl
|
|
389
|
+
active
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
result = await self.execute_query(query, {"nameFilter": name})
|
|
396
|
+
users = result.get("users", {}).get("nodes", [])
|
|
397
|
+
return [u for u in users if u.get("active", True)] # Filter active users
|
|
398
|
+
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logging.getLogger(__name__).warning(f"Failed to search users by name: {e}")
|
|
401
|
+
return []
|
|
402
|
+
|
|
269
403
|
async def close(self) -> None:
|
|
270
404
|
"""Close the client connection.
|
|
271
405
|
|
|
@@ -5,17 +5,22 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from ...core.models import Comment, Epic, Priority, Task, TicketState
|
|
8
|
+
from ...core.models import Attachment, Comment, Epic, Priority, Task, TicketState
|
|
9
9
|
from .types import extract_linear_metadata, get_universal_priority, get_universal_state
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
|
|
13
|
-
"""Convert Linear issue data to universal Task model.
|
|
13
|
+
"""Convert Linear issue or sub-issue data to universal Task model.
|
|
14
|
+
|
|
15
|
+
Handles both top-level issues (no parent) and sub-issues (child items
|
|
16
|
+
with a parent issue).
|
|
14
17
|
|
|
15
18
|
Args:
|
|
19
|
+
----
|
|
16
20
|
issue_data: Raw Linear issue data from GraphQL
|
|
17
21
|
|
|
18
22
|
Returns:
|
|
23
|
+
-------
|
|
19
24
|
Universal Task model
|
|
20
25
|
|
|
21
26
|
"""
|
|
@@ -28,10 +33,11 @@ def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
|
|
|
28
33
|
linear_priority = issue_data.get("priority", 3)
|
|
29
34
|
priority = get_universal_priority(linear_priority)
|
|
30
35
|
|
|
31
|
-
# Map state
|
|
36
|
+
# Map state with synonym matching (1M-164)
|
|
32
37
|
state_data = issue_data.get("state", {})
|
|
33
38
|
state_type = state_data.get("type", "unstarted")
|
|
34
|
-
|
|
39
|
+
state_name = state_data.get("name") # Extract state name for synonym matching
|
|
40
|
+
state = get_universal_state(state_type, state_name)
|
|
35
41
|
|
|
36
42
|
# Extract assignee
|
|
37
43
|
assignee = None
|
|
@@ -73,6 +79,9 @@ def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
|
|
|
73
79
|
issue_data["updatedAt"].replace("Z", "+00:00")
|
|
74
80
|
)
|
|
75
81
|
|
|
82
|
+
# Extract child issue IDs
|
|
83
|
+
children = extract_child_issue_ids(issue_data)
|
|
84
|
+
|
|
76
85
|
# Extract Linear-specific metadata
|
|
77
86
|
linear_metadata = extract_linear_metadata(issue_data)
|
|
78
87
|
metadata = {"linear": linear_metadata} if linear_metadata else {}
|
|
@@ -88,6 +97,7 @@ def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
|
|
|
88
97
|
tags=tags,
|
|
89
98
|
parent_epic=parent_epic,
|
|
90
99
|
parent_issue=parent_issue,
|
|
100
|
+
children=children,
|
|
91
101
|
created_at=created_at,
|
|
92
102
|
updated_at=updated_at,
|
|
93
103
|
metadata=metadata,
|
|
@@ -98,9 +108,11 @@ def map_linear_project_to_epic(project_data: dict[str, Any]) -> Epic:
|
|
|
98
108
|
"""Convert Linear project data to universal Epic model.
|
|
99
109
|
|
|
100
110
|
Args:
|
|
111
|
+
----
|
|
101
112
|
project_data: Raw Linear project data from GraphQL
|
|
102
113
|
|
|
103
114
|
Returns:
|
|
115
|
+
-------
|
|
104
116
|
Universal Epic model
|
|
105
117
|
|
|
106
118
|
"""
|
|
@@ -134,7 +146,7 @@ def map_linear_project_to_epic(project_data: dict[str, Any]) -> Epic:
|
|
|
134
146
|
)
|
|
135
147
|
|
|
136
148
|
# Extract Linear-specific metadata
|
|
137
|
-
metadata = {"linear": {}}
|
|
149
|
+
metadata: dict[str, Any] = {"linear": {}}
|
|
138
150
|
if project_data.get("url"):
|
|
139
151
|
metadata["linear"]["linear_url"] = project_data["url"]
|
|
140
152
|
if project_data.get("icon"):
|
|
@@ -162,10 +174,12 @@ def map_linear_comment_to_comment(
|
|
|
162
174
|
"""Convert Linear comment data to universal Comment model.
|
|
163
175
|
|
|
164
176
|
Args:
|
|
177
|
+
----
|
|
165
178
|
comment_data: Raw Linear comment data from GraphQL
|
|
166
179
|
ticket_id: ID of the ticket this comment belongs to
|
|
167
180
|
|
|
168
181
|
Returns:
|
|
182
|
+
-------
|
|
169
183
|
Universal Comment model
|
|
170
184
|
|
|
171
185
|
"""
|
|
@@ -203,19 +217,24 @@ def map_linear_comment_to_comment(
|
|
|
203
217
|
|
|
204
218
|
|
|
205
219
|
def build_linear_issue_input(task: Task, team_id: str) -> dict[str, Any]:
|
|
206
|
-
"""Build Linear issue input from universal Task model.
|
|
220
|
+
"""Build Linear issue or sub-issue input from universal Task model.
|
|
221
|
+
|
|
222
|
+
Creates input for a top-level issue when task.parent_issue is not set,
|
|
223
|
+
or for a sub-issue when task.parent_issue is provided.
|
|
207
224
|
|
|
208
225
|
Args:
|
|
226
|
+
----
|
|
209
227
|
task: Universal Task model
|
|
210
228
|
team_id: Linear team ID
|
|
211
229
|
|
|
212
230
|
Returns:
|
|
231
|
+
-------
|
|
213
232
|
Linear issue input dictionary
|
|
214
233
|
|
|
215
234
|
"""
|
|
216
235
|
from .types import get_linear_priority
|
|
217
236
|
|
|
218
|
-
issue_input = {
|
|
237
|
+
issue_input: dict[str, Any] = {
|
|
219
238
|
"title": task.title,
|
|
220
239
|
"teamId": team_id,
|
|
221
240
|
}
|
|
@@ -240,10 +259,19 @@ def build_linear_issue_input(task: Task, team_id: str) -> dict[str, Any]:
|
|
|
240
259
|
if task.parent_epic:
|
|
241
260
|
issue_input["projectId"] = task.parent_epic
|
|
242
261
|
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
262
|
+
# DO NOT set labelIds here - adapter will handle label resolution
|
|
263
|
+
# Labels are resolved from names to UUIDs in LinearAdapter._create_task()
|
|
264
|
+
# If we set it here, we create a type mismatch (names vs UUIDs)
|
|
265
|
+
# The adapter's label resolution logic (lines 982-988) handles this properly
|
|
266
|
+
#
|
|
267
|
+
# Bug Fix (v1.1.1): Previously, setting labelIds to tag names here caused
|
|
268
|
+
# "Argument Validation Error" from Linear's GraphQL API. The API requires
|
|
269
|
+
# UUIDs (e.g., "uuid-1"), not names (e.g., "bug"). This was fixed by:
|
|
270
|
+
# 1. Removing labelIds assignment in mapper (this file)
|
|
271
|
+
# 2. Adding UUID validation in adapter._create_task() (adapter.py:1047-1060)
|
|
272
|
+
# 3. Changing GraphQL labelIds type to [String!]! (queries.py)
|
|
273
|
+
#
|
|
274
|
+
# See: docs/TROUBLESHOOTING.md#issue-argument-validation-error-when-creating-issues-with-labels
|
|
247
275
|
|
|
248
276
|
# Add Linear-specific metadata
|
|
249
277
|
if task.metadata and "linear" in task.metadata:
|
|
@@ -262,9 +290,11 @@ def build_linear_issue_update_input(updates: dict[str, Any]) -> dict[str, Any]:
|
|
|
262
290
|
"""Build Linear issue update input from update dictionary.
|
|
263
291
|
|
|
264
292
|
Args:
|
|
293
|
+
----
|
|
265
294
|
updates: Dictionary of fields to update
|
|
266
295
|
|
|
267
296
|
Returns:
|
|
297
|
+
-------
|
|
268
298
|
Linear issue update input dictionary
|
|
269
299
|
|
|
270
300
|
"""
|
|
@@ -314,9 +344,11 @@ def extract_child_issue_ids(issue_data: dict[str, Any]) -> list[str]:
|
|
|
314
344
|
"""Extract child issue IDs from Linear issue data.
|
|
315
345
|
|
|
316
346
|
Args:
|
|
347
|
+
----
|
|
317
348
|
issue_data: Raw Linear issue data from GraphQL
|
|
318
349
|
|
|
319
350
|
Returns:
|
|
351
|
+
-------
|
|
320
352
|
List of child issue identifiers
|
|
321
353
|
|
|
322
354
|
"""
|
|
@@ -324,3 +356,65 @@ def extract_child_issue_ids(issue_data: dict[str, Any]) -> list[str]:
|
|
|
324
356
|
if issue_data.get("children", {}).get("nodes"):
|
|
325
357
|
child_ids = [child["identifier"] for child in issue_data["children"]["nodes"]]
|
|
326
358
|
return child_ids
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def map_linear_attachment_to_attachment(
|
|
362
|
+
attachment_data: dict[str, Any], ticket_id: str
|
|
363
|
+
) -> Attachment:
|
|
364
|
+
"""Convert Linear attachment data to universal Attachment model.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
----
|
|
368
|
+
attachment_data: Raw Linear attachment data from GraphQL
|
|
369
|
+
ticket_id: ID of the ticket this attachment belongs to
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
-------
|
|
373
|
+
Universal Attachment model
|
|
374
|
+
|
|
375
|
+
Note:
|
|
376
|
+
----
|
|
377
|
+
Linear attachment URLs require authentication with API key.
|
|
378
|
+
URLs are in format: https://files.linear.app/workspace/attachment-id/filename
|
|
379
|
+
Authentication header: Authorization: Bearer {api_key}
|
|
380
|
+
|
|
381
|
+
"""
|
|
382
|
+
# Extract basic fields
|
|
383
|
+
attachment_id = attachment_data.get("id")
|
|
384
|
+
title = attachment_data.get("title", "Untitled")
|
|
385
|
+
url = attachment_data.get("url")
|
|
386
|
+
subtitle = attachment_data.get("subtitle")
|
|
387
|
+
|
|
388
|
+
# Extract dates
|
|
389
|
+
created_at = None
|
|
390
|
+
if attachment_data.get("createdAt"):
|
|
391
|
+
created_at = datetime.fromisoformat(
|
|
392
|
+
attachment_data["createdAt"].replace("Z", "+00:00")
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if attachment_data.get("updatedAt"):
|
|
396
|
+
datetime.fromisoformat(attachment_data["updatedAt"].replace("Z", "+00:00"))
|
|
397
|
+
|
|
398
|
+
# Build metadata with Linear-specific fields
|
|
399
|
+
metadata = {
|
|
400
|
+
"linear": {
|
|
401
|
+
"id": attachment_id,
|
|
402
|
+
"title": title,
|
|
403
|
+
"subtitle": subtitle,
|
|
404
|
+
"metadata": attachment_data.get("metadata"),
|
|
405
|
+
"updated_at": attachment_data.get("updatedAt"),
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return Attachment(
|
|
410
|
+
id=attachment_id,
|
|
411
|
+
ticket_id=ticket_id,
|
|
412
|
+
filename=title, # Linear uses 'title' as filename
|
|
413
|
+
url=url,
|
|
414
|
+
content_type=None, # Linear doesn't provide MIME type in GraphQL
|
|
415
|
+
size_bytes=None, # Linear doesn't provide size in GraphQL
|
|
416
|
+
created_at=created_at,
|
|
417
|
+
created_by=None, # Not included in standard fragment
|
|
418
|
+
description=subtitle,
|
|
419
|
+
metadata=metadata,
|
|
420
|
+
)
|