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.

Files changed (129) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/aitrackdown.py +507 -6
  5. mcp_ticketer/adapters/asana/adapter.py +229 -0
  6. mcp_ticketer/adapters/asana/mappers.py +14 -0
  7. mcp_ticketer/adapters/github/__init__.py +26 -0
  8. mcp_ticketer/adapters/github/adapter.py +3229 -0
  9. mcp_ticketer/adapters/github/client.py +335 -0
  10. mcp_ticketer/adapters/github/mappers.py +797 -0
  11. mcp_ticketer/adapters/github/queries.py +692 -0
  12. mcp_ticketer/adapters/github/types.py +460 -0
  13. mcp_ticketer/adapters/hybrid.py +47 -5
  14. mcp_ticketer/adapters/jira/__init__.py +35 -0
  15. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  16. mcp_ticketer/adapters/jira/client.py +271 -0
  17. mcp_ticketer/adapters/jira/mappers.py +246 -0
  18. mcp_ticketer/adapters/jira/queries.py +216 -0
  19. mcp_ticketer/adapters/jira/types.py +304 -0
  20. mcp_ticketer/adapters/linear/adapter.py +2730 -139
  21. mcp_ticketer/adapters/linear/client.py +175 -3
  22. mcp_ticketer/adapters/linear/mappers.py +203 -8
  23. mcp_ticketer/adapters/linear/queries.py +280 -3
  24. mcp_ticketer/adapters/linear/types.py +120 -4
  25. mcp_ticketer/analysis/__init__.py +56 -0
  26. mcp_ticketer/analysis/dependency_graph.py +255 -0
  27. mcp_ticketer/analysis/health_assessment.py +304 -0
  28. mcp_ticketer/analysis/orphaned.py +218 -0
  29. mcp_ticketer/analysis/project_status.py +594 -0
  30. mcp_ticketer/analysis/similarity.py +224 -0
  31. mcp_ticketer/analysis/staleness.py +266 -0
  32. mcp_ticketer/automation/__init__.py +11 -0
  33. mcp_ticketer/automation/project_updates.py +378 -0
  34. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  35. mcp_ticketer/cli/auggie_configure.py +17 -5
  36. mcp_ticketer/cli/codex_configure.py +97 -61
  37. mcp_ticketer/cli/configure.py +1288 -105
  38. mcp_ticketer/cli/cursor_configure.py +314 -0
  39. mcp_ticketer/cli/diagnostics.py +13 -12
  40. mcp_ticketer/cli/discover.py +5 -0
  41. mcp_ticketer/cli/gemini_configure.py +17 -5
  42. mcp_ticketer/cli/init_command.py +880 -0
  43. mcp_ticketer/cli/install_mcp_server.py +418 -0
  44. mcp_ticketer/cli/instruction_commands.py +6 -0
  45. mcp_ticketer/cli/main.py +267 -3175
  46. mcp_ticketer/cli/mcp_configure.py +821 -119
  47. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  48. mcp_ticketer/cli/platform_detection.py +77 -12
  49. mcp_ticketer/cli/platform_installer.py +545 -0
  50. mcp_ticketer/cli/project_update_commands.py +350 -0
  51. mcp_ticketer/cli/setup_command.py +795 -0
  52. mcp_ticketer/cli/simple_health.py +12 -10
  53. mcp_ticketer/cli/ticket_commands.py +705 -103
  54. mcp_ticketer/cli/utils.py +113 -0
  55. mcp_ticketer/core/__init__.py +56 -6
  56. mcp_ticketer/core/adapter.py +533 -2
  57. mcp_ticketer/core/config.py +21 -21
  58. mcp_ticketer/core/exceptions.py +7 -1
  59. mcp_ticketer/core/label_manager.py +732 -0
  60. mcp_ticketer/core/mappers.py +31 -19
  61. mcp_ticketer/core/milestone_manager.py +252 -0
  62. mcp_ticketer/core/models.py +480 -0
  63. mcp_ticketer/core/onepassword_secrets.py +1 -1
  64. mcp_ticketer/core/priority_matcher.py +463 -0
  65. mcp_ticketer/core/project_config.py +132 -14
  66. mcp_ticketer/core/project_utils.py +281 -0
  67. mcp_ticketer/core/project_validator.py +376 -0
  68. mcp_ticketer/core/session_state.py +176 -0
  69. mcp_ticketer/core/state_matcher.py +625 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/mcp/server/__main__.py +2 -1
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/main.py +106 -25
  75. mcp_ticketer/mcp/server/routing.py +723 -0
  76. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  77. mcp_ticketer/mcp/server/tools/__init__.py +33 -11
  78. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  79. mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
  80. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  81. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  82. mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
  83. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  84. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  85. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  86. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  87. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  88. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  89. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  90. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  91. mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
  92. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  93. mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
  94. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  95. mcp_ticketer/queue/queue.py +68 -0
  96. mcp_ticketer/queue/worker.py +1 -1
  97. mcp_ticketer/utils/__init__.py +5 -0
  98. mcp_ticketer/utils/token_utils.py +246 -0
  99. mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
  100. mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
  101. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  102. py_mcp_installer/examples/phase3_demo.py +178 -0
  103. py_mcp_installer/scripts/manage_version.py +54 -0
  104. py_mcp_installer/setup.py +6 -0
  105. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  106. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  107. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  108. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  109. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  110. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  111. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  112. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  113. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  114. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  115. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  116. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  117. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  118. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  119. py_mcp_installer/tests/__init__.py +0 -0
  120. py_mcp_installer/tests/platforms/__init__.py +0 -0
  121. py_mcp_installer/tests/test_platform_detector.py +17 -0
  122. mcp_ticketer/adapters/github.py +0 -1574
  123. mcp_ticketer/adapters/jira.py +0 -1258
  124. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  125. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  126. mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
  127. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  128. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  129. {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
- except Exception:
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
- state = get_universal_state(state_type)
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
- # Add labels (tags) if provided
250
- if task.tags:
251
- # Note: This returns label names, will be resolved to IDs by adapter
252
- issue_input["labelIds"] = task.tags # Temporary - adapter will resolve
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