mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__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 +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -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/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- 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 +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -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 +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
8
10
|
try:
|
|
9
11
|
from gql import Client, gql
|
|
10
|
-
from gql.transport.exceptions import TransportError
|
|
12
|
+
from gql.transport.exceptions import TransportError, TransportQueryError
|
|
11
13
|
from gql.transport.httpx import HTTPXAsyncTransport
|
|
12
14
|
except ImportError:
|
|
13
15
|
# Handle missing gql dependency gracefully
|
|
@@ -15,9 +17,12 @@ except ImportError:
|
|
|
15
17
|
gql = None
|
|
16
18
|
HTTPXAsyncTransport = None
|
|
17
19
|
TransportError = Exception
|
|
20
|
+
TransportQueryError = Exception
|
|
18
21
|
|
|
19
22
|
from ...core.exceptions import AdapterError, AuthenticationError, RateLimitError
|
|
20
23
|
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
class LinearGraphQLClient:
|
|
23
28
|
"""GraphQL client for Linear API with error handling and retry logic."""
|
|
@@ -26,6 +31,7 @@ class LinearGraphQLClient:
|
|
|
26
31
|
"""Initialize the Linear GraphQL client.
|
|
27
32
|
|
|
28
33
|
Args:
|
|
34
|
+
----
|
|
29
35
|
api_key: Linear API key
|
|
30
36
|
timeout: Request timeout in seconds
|
|
31
37
|
|
|
@@ -38,9 +44,11 @@ class LinearGraphQLClient:
|
|
|
38
44
|
"""Create a new GraphQL client instance.
|
|
39
45
|
|
|
40
46
|
Returns:
|
|
47
|
+
-------
|
|
41
48
|
Configured GraphQL client
|
|
42
49
|
|
|
43
50
|
Raises:
|
|
51
|
+
------
|
|
44
52
|
AuthenticationError: If API key is invalid
|
|
45
53
|
AdapterError: If client creation fails
|
|
46
54
|
|
|
@@ -80,14 +88,17 @@ class LinearGraphQLClient:
|
|
|
80
88
|
"""Execute a GraphQL query with error handling and retries.
|
|
81
89
|
|
|
82
90
|
Args:
|
|
91
|
+
----
|
|
83
92
|
query_string: GraphQL query string
|
|
84
93
|
variables: Query variables
|
|
85
94
|
retries: Number of retry attempts
|
|
86
95
|
|
|
87
96
|
Returns:
|
|
97
|
+
-------
|
|
88
98
|
Query result data
|
|
89
99
|
|
|
90
100
|
Raises:
|
|
101
|
+
------
|
|
91
102
|
AuthenticationError: If authentication fails
|
|
92
103
|
RateLimitError: If rate limit is exceeded
|
|
93
104
|
AdapterError: If query execution fails
|
|
@@ -95,16 +106,119 @@ class LinearGraphQLClient:
|
|
|
95
106
|
"""
|
|
96
107
|
query = gql(query_string)
|
|
97
108
|
|
|
109
|
+
# Extract operation name from query for logging
|
|
110
|
+
operation_name = "unknown"
|
|
111
|
+
try:
|
|
112
|
+
# Simple extraction - look for 'query' or 'mutation' keyword
|
|
113
|
+
query_lower = query_string.strip().lower()
|
|
114
|
+
if query_lower.startswith("mutation"):
|
|
115
|
+
operation_name = (
|
|
116
|
+
query_string.split("{")[0].strip().replace("mutation", "").strip()
|
|
117
|
+
)
|
|
118
|
+
elif query_lower.startswith("query"):
|
|
119
|
+
operation_name = (
|
|
120
|
+
query_string.split("{")[0].strip().replace("query", "").strip()
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass # Use default 'unknown' if extraction fails
|
|
124
|
+
|
|
98
125
|
for attempt in range(retries + 1):
|
|
99
126
|
try:
|
|
127
|
+
# Log request details before execution
|
|
128
|
+
logger.debug(
|
|
129
|
+
f"[Linear GraphQL] Executing operation: {operation_name}\n"
|
|
130
|
+
f"Variables:\n{json.dumps(variables or {}, indent=2, default=str)}"
|
|
131
|
+
)
|
|
132
|
+
|
|
100
133
|
client = self.create_client()
|
|
101
134
|
async with client as session:
|
|
102
135
|
result = await session.execute(
|
|
103
136
|
query, variable_values=variables or {}
|
|
104
137
|
)
|
|
138
|
+
|
|
139
|
+
# Log successful response
|
|
140
|
+
logger.debug(
|
|
141
|
+
f"[Linear GraphQL] Operation successful: {operation_name}\n"
|
|
142
|
+
f"Response:\n{json.dumps(result, indent=2, default=str)}"
|
|
143
|
+
)
|
|
144
|
+
|
|
105
145
|
return result
|
|
106
146
|
|
|
147
|
+
except TransportQueryError as e:
|
|
148
|
+
"""
|
|
149
|
+
Handle GraphQL validation errors (e.g., duplicate label names).
|
|
150
|
+
TransportQueryError is a subclass of TransportError with .errors attribute.
|
|
151
|
+
|
|
152
|
+
Related: 1M-398 - Label duplicate error handling
|
|
153
|
+
"""
|
|
154
|
+
# Log detailed error information
|
|
155
|
+
logger.error(
|
|
156
|
+
f"[Linear GraphQL] TransportQueryError occurred\n"
|
|
157
|
+
f"Operation: {operation_name}\n"
|
|
158
|
+
f"Variables:\n{json.dumps(variables or {}, indent=2, default=str)}\n"
|
|
159
|
+
f"Error: {e}\n"
|
|
160
|
+
f"Error details: {e.errors if hasattr(e, 'errors') else 'No error details'}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if e.errors:
|
|
164
|
+
error = e.errors[0]
|
|
165
|
+
error_msg = error.get("message", "Unknown GraphQL error")
|
|
166
|
+
|
|
167
|
+
# Parse extensions for field-specific details (enhanced debugging)
|
|
168
|
+
extensions = error.get("extensions", {})
|
|
169
|
+
|
|
170
|
+
# Check for user-presentable message (clearer error for users)
|
|
171
|
+
user_message = extensions.get("userPresentableMessage")
|
|
172
|
+
if user_message:
|
|
173
|
+
error_msg = user_message
|
|
174
|
+
|
|
175
|
+
# Check for argument path (which field failed validation)
|
|
176
|
+
arg_path = extensions.get("argumentPath")
|
|
177
|
+
if arg_path:
|
|
178
|
+
field_path = ".".join(str(p) for p in arg_path)
|
|
179
|
+
error_msg = f"{error_msg} (field: {field_path})"
|
|
180
|
+
|
|
181
|
+
# Check for validation errors (additional context)
|
|
182
|
+
validation_errors = extensions.get("validationErrors")
|
|
183
|
+
if validation_errors:
|
|
184
|
+
error_msg = (
|
|
185
|
+
f"{error_msg}\nValidation errors: {validation_errors}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Log full error context for debugging
|
|
189
|
+
logger.error(
|
|
190
|
+
"Linear GraphQL error: %s (extensions: %s)",
|
|
191
|
+
error_msg,
|
|
192
|
+
extensions,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Check for duplicate label errors specifically
|
|
196
|
+
if (
|
|
197
|
+
"duplicate" in error_msg.lower()
|
|
198
|
+
and "label" in error_msg.lower()
|
|
199
|
+
):
|
|
200
|
+
raise AdapterError(
|
|
201
|
+
f"Label already exists: {error_msg}", "linear"
|
|
202
|
+
) from e
|
|
203
|
+
|
|
204
|
+
# Other validation errors
|
|
205
|
+
raise AdapterError(
|
|
206
|
+
f"Linear GraphQL validation error: {error_msg}", "linear"
|
|
207
|
+
) from e
|
|
208
|
+
|
|
209
|
+
# Fallback if no errors attribute
|
|
210
|
+
raise AdapterError(f"Linear GraphQL error: {e}", "linear") from e
|
|
211
|
+
|
|
107
212
|
except TransportError as e:
|
|
213
|
+
# Log transport error details
|
|
214
|
+
logger.error(
|
|
215
|
+
f"[Linear GraphQL] TransportError occurred\n"
|
|
216
|
+
f"Operation: {operation_name}\n"
|
|
217
|
+
f"Variables:\n{json.dumps(variables or {}, indent=2, default=str)}\n"
|
|
218
|
+
f"Error: {e}\n"
|
|
219
|
+
f"Status code: {e.response.status if hasattr(e, 'response') and e.response else 'N/A'}"
|
|
220
|
+
)
|
|
221
|
+
|
|
108
222
|
# Handle HTTP errors
|
|
109
223
|
if hasattr(e, "response") and e.response:
|
|
110
224
|
status_code = e.response.status
|
|
@@ -139,6 +253,16 @@ class LinearGraphQLClient:
|
|
|
139
253
|
raise AdapterError(f"Linear API transport error: {e}", "linear") from e
|
|
140
254
|
|
|
141
255
|
except Exception as e:
|
|
256
|
+
# Log generic error details
|
|
257
|
+
logger.error(
|
|
258
|
+
f"[Linear GraphQL] Unexpected error occurred\n"
|
|
259
|
+
f"Operation: {operation_name}\n"
|
|
260
|
+
f"Variables:\n{json.dumps(variables or {}, indent=2, default=str)}\n"
|
|
261
|
+
f"Error type: {type(e).__name__}\n"
|
|
262
|
+
f"Error: {e}",
|
|
263
|
+
exc_info=True,
|
|
264
|
+
)
|
|
265
|
+
|
|
142
266
|
# GraphQL or other errors
|
|
143
267
|
error_msg = str(e)
|
|
144
268
|
|
|
@@ -175,14 +299,17 @@ class LinearGraphQLClient:
|
|
|
175
299
|
"""Execute a GraphQL mutation with error handling.
|
|
176
300
|
|
|
177
301
|
Args:
|
|
302
|
+
----
|
|
178
303
|
mutation_string: GraphQL mutation string
|
|
179
304
|
variables: Mutation variables
|
|
180
305
|
retries: Number of retry attempts
|
|
181
306
|
|
|
182
307
|
Returns:
|
|
308
|
+
-------
|
|
183
309
|
Mutation result data
|
|
184
310
|
|
|
185
311
|
Raises:
|
|
312
|
+
------
|
|
186
313
|
AuthenticationError: If authentication fails
|
|
187
314
|
RateLimitError: If rate limit is exceeded
|
|
188
315
|
AdapterError: If mutation execution fails
|
|
@@ -194,9 +321,20 @@ class LinearGraphQLClient:
|
|
|
194
321
|
"""Test the connection to Linear API.
|
|
195
322
|
|
|
196
323
|
Returns:
|
|
324
|
+
-------
|
|
197
325
|
True if connection is successful, False otherwise
|
|
198
326
|
|
|
327
|
+
Design Decision: Enhanced Debug Logging (1M-431)
|
|
328
|
+
-------------------------------------------------
|
|
329
|
+
Added comprehensive logging to diagnose connection failures.
|
|
330
|
+
Logs API key preview, query results, and specific failure reasons
|
|
331
|
+
to help users troubleshoot authentication and configuration issues.
|
|
332
|
+
|
|
199
333
|
"""
|
|
334
|
+
import logging
|
|
335
|
+
|
|
336
|
+
logger = logging.getLogger(__name__)
|
|
337
|
+
|
|
200
338
|
try:
|
|
201
339
|
# Simple query to test authentication
|
|
202
340
|
test_query = """
|
|
@@ -204,23 +342,53 @@ class LinearGraphQLClient:
|
|
|
204
342
|
viewer {
|
|
205
343
|
id
|
|
206
344
|
name
|
|
345
|
+
email
|
|
207
346
|
}
|
|
208
347
|
}
|
|
209
348
|
"""
|
|
210
349
|
|
|
350
|
+
logger.debug(
|
|
351
|
+
f"Testing Linear API connection with API key: {self.api_key[:20]}..."
|
|
352
|
+
)
|
|
211
353
|
result = await self.execute_query(test_query)
|
|
212
|
-
return bool(result.get("viewer"))
|
|
213
354
|
|
|
214
|
-
|
|
355
|
+
# Log the actual response for debugging
|
|
356
|
+
logger.debug(f"Linear API test response: {result}")
|
|
357
|
+
|
|
358
|
+
viewer = result.get("viewer")
|
|
359
|
+
|
|
360
|
+
if not viewer:
|
|
361
|
+
logger.warning(
|
|
362
|
+
f"Linear test connection query succeeded but returned no viewer data. "
|
|
363
|
+
f"Response: {result}"
|
|
364
|
+
)
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
if not viewer.get("id"):
|
|
368
|
+
logger.warning(f"Linear viewer missing id field. Viewer data: {viewer}")
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
logger.info(
|
|
372
|
+
f"Linear API connected successfully as: {viewer.get('name')} ({viewer.get('email')})"
|
|
373
|
+
)
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
except Exception as e:
|
|
377
|
+
logger.error(
|
|
378
|
+
f"Linear connection test failed: {type(e).__name__}: {e}",
|
|
379
|
+
exc_info=True,
|
|
380
|
+
)
|
|
215
381
|
return False
|
|
216
382
|
|
|
217
383
|
async def get_team_info(self, team_id: str) -> dict[str, Any] | None:
|
|
218
384
|
"""Get team information by ID.
|
|
219
385
|
|
|
220
386
|
Args:
|
|
387
|
+
----
|
|
221
388
|
team_id: Linear team ID
|
|
222
389
|
|
|
223
390
|
Returns:
|
|
391
|
+
-------
|
|
224
392
|
Team information or None if not found
|
|
225
393
|
|
|
226
394
|
"""
|
|
@@ -246,9 +414,11 @@ class LinearGraphQLClient:
|
|
|
246
414
|
"""Get user information by email.
|
|
247
415
|
|
|
248
416
|
Args:
|
|
417
|
+
----
|
|
249
418
|
email: User email address
|
|
250
419
|
|
|
251
420
|
Returns:
|
|
421
|
+
-------
|
|
252
422
|
User information or None if not found
|
|
253
423
|
|
|
254
424
|
"""
|
|
@@ -278,9 +448,11 @@ class LinearGraphQLClient:
|
|
|
278
448
|
"""Search users by display name or full name.
|
|
279
449
|
|
|
280
450
|
Args:
|
|
451
|
+
----
|
|
281
452
|
name: Display name or full name to search for
|
|
282
453
|
|
|
283
454
|
Returns:
|
|
455
|
+
-------
|
|
284
456
|
List of matching users (may be empty)
|
|
285
457
|
|
|
286
458
|
"""
|
|
@@ -5,7 +5,7 @@ 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
|
|
|
@@ -16,9 +16,11 @@ def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
|
|
|
16
16
|
with a parent issue).
|
|
17
17
|
|
|
18
18
|
Args:
|
|
19
|
+
----
|
|
19
20
|
issue_data: Raw Linear issue data from GraphQL
|
|
20
21
|
|
|
21
22
|
Returns:
|
|
23
|
+
-------
|
|
22
24
|
Universal Task model
|
|
23
25
|
|
|
24
26
|
"""
|
|
@@ -31,10 +33,11 @@ def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
|
|
|
31
33
|
linear_priority = issue_data.get("priority", 3)
|
|
32
34
|
priority = get_universal_priority(linear_priority)
|
|
33
35
|
|
|
34
|
-
# Map state
|
|
36
|
+
# Map state with synonym matching (1M-164)
|
|
35
37
|
state_data = issue_data.get("state", {})
|
|
36
38
|
state_type = state_data.get("type", "unstarted")
|
|
37
|
-
|
|
39
|
+
state_name = state_data.get("name") # Extract state name for synonym matching
|
|
40
|
+
state = get_universal_state(state_type, state_name)
|
|
38
41
|
|
|
39
42
|
# Extract assignee
|
|
40
43
|
assignee = None
|
|
@@ -76,6 +79,9 @@ def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
|
|
|
76
79
|
issue_data["updatedAt"].replace("Z", "+00:00")
|
|
77
80
|
)
|
|
78
81
|
|
|
82
|
+
# Extract child issue IDs
|
|
83
|
+
children = extract_child_issue_ids(issue_data)
|
|
84
|
+
|
|
79
85
|
# Extract Linear-specific metadata
|
|
80
86
|
linear_metadata = extract_linear_metadata(issue_data)
|
|
81
87
|
metadata = {"linear": linear_metadata} if linear_metadata else {}
|
|
@@ -91,6 +97,7 @@ def map_linear_issue_to_task(issue_data: dict[str, Any]) -> Task:
|
|
|
91
97
|
tags=tags,
|
|
92
98
|
parent_epic=parent_epic,
|
|
93
99
|
parent_issue=parent_issue,
|
|
100
|
+
children=children,
|
|
94
101
|
created_at=created_at,
|
|
95
102
|
updated_at=updated_at,
|
|
96
103
|
metadata=metadata,
|
|
@@ -101,9 +108,11 @@ def map_linear_project_to_epic(project_data: dict[str, Any]) -> Epic:
|
|
|
101
108
|
"""Convert Linear project data to universal Epic model.
|
|
102
109
|
|
|
103
110
|
Args:
|
|
111
|
+
----
|
|
104
112
|
project_data: Raw Linear project data from GraphQL
|
|
105
113
|
|
|
106
114
|
Returns:
|
|
115
|
+
-------
|
|
107
116
|
Universal Epic model
|
|
108
117
|
|
|
109
118
|
"""
|
|
@@ -137,7 +146,7 @@ def map_linear_project_to_epic(project_data: dict[str, Any]) -> Epic:
|
|
|
137
146
|
)
|
|
138
147
|
|
|
139
148
|
# Extract Linear-specific metadata
|
|
140
|
-
metadata = {"linear": {}}
|
|
149
|
+
metadata: dict[str, Any] = {"linear": {}}
|
|
141
150
|
if project_data.get("url"):
|
|
142
151
|
metadata["linear"]["linear_url"] = project_data["url"]
|
|
143
152
|
if project_data.get("icon"):
|
|
@@ -165,10 +174,12 @@ def map_linear_comment_to_comment(
|
|
|
165
174
|
"""Convert Linear comment data to universal Comment model.
|
|
166
175
|
|
|
167
176
|
Args:
|
|
177
|
+
----
|
|
168
178
|
comment_data: Raw Linear comment data from GraphQL
|
|
169
179
|
ticket_id: ID of the ticket this comment belongs to
|
|
170
180
|
|
|
171
181
|
Returns:
|
|
182
|
+
-------
|
|
172
183
|
Universal Comment model
|
|
173
184
|
|
|
174
185
|
"""
|
|
@@ -212,10 +223,12 @@ def build_linear_issue_input(task: Task, team_id: str) -> dict[str, Any]:
|
|
|
212
223
|
or for a sub-issue when task.parent_issue is provided.
|
|
213
224
|
|
|
214
225
|
Args:
|
|
226
|
+
----
|
|
215
227
|
task: Universal Task model
|
|
216
228
|
team_id: Linear team ID
|
|
217
229
|
|
|
218
230
|
Returns:
|
|
231
|
+
-------
|
|
219
232
|
Linear issue input dictionary
|
|
220
233
|
|
|
221
234
|
"""
|
|
@@ -246,10 +259,19 @@ def build_linear_issue_input(task: Task, team_id: str) -> dict[str, Any]:
|
|
|
246
259
|
if task.parent_epic:
|
|
247
260
|
issue_input["projectId"] = task.parent_epic
|
|
248
261
|
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
253
275
|
|
|
254
276
|
# Add Linear-specific metadata
|
|
255
277
|
if task.metadata and "linear" in task.metadata:
|
|
@@ -268,9 +290,11 @@ def build_linear_issue_update_input(updates: dict[str, Any]) -> dict[str, Any]:
|
|
|
268
290
|
"""Build Linear issue update input from update dictionary.
|
|
269
291
|
|
|
270
292
|
Args:
|
|
293
|
+
----
|
|
271
294
|
updates: Dictionary of fields to update
|
|
272
295
|
|
|
273
296
|
Returns:
|
|
297
|
+
-------
|
|
274
298
|
Linear issue update input dictionary
|
|
275
299
|
|
|
276
300
|
"""
|
|
@@ -320,9 +344,11 @@ def extract_child_issue_ids(issue_data: dict[str, Any]) -> list[str]:
|
|
|
320
344
|
"""Extract child issue IDs from Linear issue data.
|
|
321
345
|
|
|
322
346
|
Args:
|
|
347
|
+
----
|
|
323
348
|
issue_data: Raw Linear issue data from GraphQL
|
|
324
349
|
|
|
325
350
|
Returns:
|
|
351
|
+
-------
|
|
326
352
|
List of child issue identifiers
|
|
327
353
|
|
|
328
354
|
"""
|
|
@@ -330,3 +356,172 @@ def extract_child_issue_ids(issue_data: dict[str, Any]) -> list[str]:
|
|
|
330
356
|
if issue_data.get("children", {}).get("nodes"):
|
|
331
357
|
child_ids = [child["identifier"] for child in issue_data["children"]["nodes"]]
|
|
332
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
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def task_to_compact_format(task: Task) -> dict[str, Any]:
|
|
424
|
+
"""Convert Task to compact format for efficient token usage.
|
|
425
|
+
|
|
426
|
+
Compact format includes only essential fields, reducing token usage by ~70-80%
|
|
427
|
+
compared to full Task serialization.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
----
|
|
431
|
+
task: Universal Task model
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
-------
|
|
435
|
+
Compact dictionary with minimal essential fields
|
|
436
|
+
|
|
437
|
+
Design Decision: Compact Format Fields (1M-554)
|
|
438
|
+
-----------------------------------------------
|
|
439
|
+
Rationale: Selected fields based on token efficiency analysis and user needs.
|
|
440
|
+
Full Task serialization averages ~600 tokens per item. Compact format targets
|
|
441
|
+
~120 tokens per item (80% reduction).
|
|
442
|
+
|
|
443
|
+
Essential fields included:
|
|
444
|
+
- id: Required for all operations (identifier)
|
|
445
|
+
- title: Primary user-facing information
|
|
446
|
+
- state: Critical for workflow understanding
|
|
447
|
+
- priority: Important for triage and filtering
|
|
448
|
+
- assignee: Key for task assignment visibility
|
|
449
|
+
|
|
450
|
+
Fields excluded:
|
|
451
|
+
- description: Often large (100-500 tokens), available via get()
|
|
452
|
+
- creator: Less critical for list views
|
|
453
|
+
- tags: Available in full format, not essential for scanning
|
|
454
|
+
- children: Hierarchy details available via dedicated queries
|
|
455
|
+
- created_at/updated_at: Not essential for list scanning
|
|
456
|
+
- metadata: Platform-specific, not needed in compact view
|
|
457
|
+
|
|
458
|
+
Performance: Reduces typical 50-item list from ~30,000 to ~6,000 tokens.
|
|
459
|
+
|
|
460
|
+
"""
|
|
461
|
+
# Handle state - can be TicketState enum or string
|
|
462
|
+
state_value = None
|
|
463
|
+
if task.state:
|
|
464
|
+
state_value = task.state.value if hasattr(task.state, "value") else task.state
|
|
465
|
+
|
|
466
|
+
# Handle priority - can be Priority enum or string
|
|
467
|
+
priority_value = None
|
|
468
|
+
if task.priority:
|
|
469
|
+
priority_value = (
|
|
470
|
+
task.priority.value if hasattr(task.priority, "value") else task.priority
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
"id": task.id,
|
|
475
|
+
"title": task.title,
|
|
476
|
+
"state": state_value,
|
|
477
|
+
"priority": priority_value,
|
|
478
|
+
"assignee": task.assignee,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def epic_to_compact_format(epic: Epic) -> dict[str, Any]:
|
|
483
|
+
"""Convert Epic to compact format for efficient token usage.
|
|
484
|
+
|
|
485
|
+
Compact format includes only essential fields, reducing token usage by ~70-80%
|
|
486
|
+
compared to full Epic serialization.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
----
|
|
490
|
+
epic: Universal Epic model
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
-------
|
|
494
|
+
Compact dictionary with minimal essential fields
|
|
495
|
+
|
|
496
|
+
Design Decision: Epic Compact Format (1M-554)
|
|
497
|
+
---------------------------------------------
|
|
498
|
+
Rationale: Epics typically have less metadata than tasks, but descriptions
|
|
499
|
+
can still be large. Compact format focuses on overview information.
|
|
500
|
+
|
|
501
|
+
Essential fields:
|
|
502
|
+
- id: Required for all operations
|
|
503
|
+
- title: Primary identifier
|
|
504
|
+
- state: Project status
|
|
505
|
+
- child_count: Useful for project overview (if available)
|
|
506
|
+
|
|
507
|
+
Performance: Similar token reduction to task compact format.
|
|
508
|
+
|
|
509
|
+
"""
|
|
510
|
+
# Handle state - can be TicketState enum or string
|
|
511
|
+
state_value = None
|
|
512
|
+
if epic.state:
|
|
513
|
+
state_value = epic.state.value if hasattr(epic.state, "value") else epic.state
|
|
514
|
+
|
|
515
|
+
compact = {
|
|
516
|
+
"id": epic.id,
|
|
517
|
+
"title": epic.title,
|
|
518
|
+
"state": state_value,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
# Include child count if available in metadata
|
|
522
|
+
if epic.metadata and "linear" in epic.metadata:
|
|
523
|
+
linear_meta = epic.metadata["linear"]
|
|
524
|
+
if "issue_count" in linear_meta:
|
|
525
|
+
compact["child_count"] = linear_meta["issue_count"]
|
|
526
|
+
|
|
527
|
+
return compact
|