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.

Files changed (111) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +394 -9
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +836 -105
  11. mcp_ticketer/adapters/hybrid.py +47 -5
  12. mcp_ticketer/adapters/jira.py +772 -1
  13. mcp_ticketer/adapters/linear/adapter.py +2293 -108
  14. mcp_ticketer/adapters/linear/client.py +146 -12
  15. mcp_ticketer/adapters/linear/mappers.py +105 -11
  16. mcp_ticketer/adapters/linear/queries.py +168 -1
  17. mcp_ticketer/adapters/linear/types.py +80 -4
  18. mcp_ticketer/analysis/__init__.py +56 -0
  19. mcp_ticketer/analysis/dependency_graph.py +255 -0
  20. mcp_ticketer/analysis/health_assessment.py +304 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/project_status.py +594 -0
  23. mcp_ticketer/analysis/similarity.py +224 -0
  24. mcp_ticketer/analysis/staleness.py +266 -0
  25. mcp_ticketer/automation/__init__.py +11 -0
  26. mcp_ticketer/automation/project_updates.py +378 -0
  27. mcp_ticketer/cache/memory.py +3 -3
  28. mcp_ticketer/cli/adapter_diagnostics.py +4 -2
  29. mcp_ticketer/cli/auggie_configure.py +18 -6
  30. mcp_ticketer/cli/codex_configure.py +175 -60
  31. mcp_ticketer/cli/configure.py +884 -146
  32. mcp_ticketer/cli/cursor_configure.py +314 -0
  33. mcp_ticketer/cli/diagnostics.py +31 -28
  34. mcp_ticketer/cli/discover.py +293 -21
  35. mcp_ticketer/cli/gemini_configure.py +18 -6
  36. mcp_ticketer/cli/init_command.py +880 -0
  37. mcp_ticketer/cli/instruction_commands.py +435 -0
  38. mcp_ticketer/cli/linear_commands.py +99 -15
  39. mcp_ticketer/cli/main.py +109 -2055
  40. mcp_ticketer/cli/mcp_configure.py +673 -99
  41. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  42. mcp_ticketer/cli/migrate_config.py +12 -8
  43. mcp_ticketer/cli/platform_commands.py +6 -6
  44. mcp_ticketer/cli/platform_detection.py +477 -0
  45. mcp_ticketer/cli/platform_installer.py +536 -0
  46. mcp_ticketer/cli/project_update_commands.py +350 -0
  47. mcp_ticketer/cli/queue_commands.py +15 -15
  48. mcp_ticketer/cli/setup_command.py +639 -0
  49. mcp_ticketer/cli/simple_health.py +13 -11
  50. mcp_ticketer/cli/ticket_commands.py +277 -36
  51. mcp_ticketer/cli/update_checker.py +313 -0
  52. mcp_ticketer/cli/utils.py +45 -41
  53. mcp_ticketer/core/__init__.py +35 -1
  54. mcp_ticketer/core/adapter.py +170 -5
  55. mcp_ticketer/core/config.py +38 -31
  56. mcp_ticketer/core/env_discovery.py +33 -3
  57. mcp_ticketer/core/env_loader.py +7 -6
  58. mcp_ticketer/core/exceptions.py +10 -4
  59. mcp_ticketer/core/http_client.py +10 -10
  60. mcp_ticketer/core/instructions.py +405 -0
  61. mcp_ticketer/core/label_manager.py +732 -0
  62. mcp_ticketer/core/mappers.py +32 -20
  63. mcp_ticketer/core/models.py +136 -1
  64. mcp_ticketer/core/onepassword_secrets.py +379 -0
  65. mcp_ticketer/core/priority_matcher.py +463 -0
  66. mcp_ticketer/core/project_config.py +148 -14
  67. mcp_ticketer/core/registry.py +1 -1
  68. mcp_ticketer/core/session_state.py +171 -0
  69. mcp_ticketer/core/state_matcher.py +592 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  73. mcp_ticketer/mcp/__init__.py +2 -2
  74. mcp_ticketer/mcp/server/__init__.py +2 -2
  75. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  76. mcp_ticketer/mcp/server/main.py +187 -93
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +37 -9
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  90. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  91. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  92. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  93. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  94. mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
  95. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  96. mcp_ticketer/queue/health_monitor.py +1 -0
  97. mcp_ticketer/queue/manager.py +4 -4
  98. mcp_ticketer/queue/queue.py +3 -3
  99. mcp_ticketer/queue/run_worker.py +1 -1
  100. mcp_ticketer/queue/ticket_registry.py +2 -2
  101. mcp_ticketer/queue/worker.py +15 -13
  102. mcp_ticketer/utils/__init__.py +5 -0
  103. mcp_ticketer/utils/token_utils.py +246 -0
  104. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  105. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  106. mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
  107. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  108. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  109. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  110. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  111. {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("Invalid Linear API key", "linear")
147
+ raise AuthenticationError(
148
+ "Invalid Linear API key", "linear"
149
+ ) from e
114
150
  elif status_code == 403:
115
- raise AuthenticationError("Insufficient permissions", "linear")
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("Linear API rate limit exceeded", "linear")
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(f"Linear GraphQL error: {error_msg}", "linear")
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
- except Exception:
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
- 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)
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
- # Add labels (tags) if provided
244
- if task.tags:
245
- # Note: This returns label names, will be resolved to IDs by adapter
246
- 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
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
+ )